From f6ac4797275eb99b6c6f20941b819c1b17102df3 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 20 Oct 2020 17:46:08 +0100 Subject: [PATCH 01/16] init modal for saving timeline --- .../components/flyout/header/index.tsx | 3 + .../timeline/header/save_timeline.tsx | 163 ++++++++++++++++++ .../timeline/header/translations.ts | 32 ++++ .../timeline/properties/helpers.tsx | 75 +++++--- .../components/timeline/properties/index.tsx | 11 ++ .../timeline/properties/properties_left.tsx | 18 +- .../components/timeline/properties/styles.tsx | 22 ++- 7 files changed, 293 insertions(+), 31 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx 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 a711e7a1d0442..b4e4911213b6d 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 @@ -39,6 +39,7 @@ const StatefulFlyoutHeader = React.memo( noteIds, notesById, status, + saveTimeline, timelineId, timelineType, title, @@ -64,6 +65,7 @@ const StatefulFlyoutHeader = React.memo( isFavorite={isFavorite} noteIds={noteIds} status={status} + saveTimeline={saveTimeline} timelineId={timelineId} timelineType={timelineType} title={title} @@ -134,6 +136,7 @@ const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ dispatch(timelineActions.updateTitle({ id, title })), toggleLock: ({ linkToId }: { linkToId: InputsModelId }) => dispatch(inputsActions.toggleTimelineLinkTo({ linkToId })), + saveTimeline: ({ id }: { id: string }) => dispatch(timelineActions.startTimelineSaving({ id })), }); const connector = connect(makeMapStateToProps, mapDispatchToProps); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx new file mode 100644 index 0000000000000..d7f79dcecef0c --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx @@ -0,0 +1,163 @@ +/* + * 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 { + EuiButton, + EuiButtonIcon, + EuiFlexGroup, + EuiFormRow, + EuiFlexItem, + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiOverlayMask, + EuiSpacer, + EuiToolTip, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { timelineSelectors } from '../../../../timelines/store/timeline'; +import { + Description, + Name, + UpdateTitle, + UpdateDescription, + SaveTimeline, +} from '../properties/helpers'; +import { NOTES_PANEL_WIDTH } from '../properties/notes_size'; +import { TIMELINE_TITLE, DESCRIPTION } from '../properties/translations'; +import * as i18n from './translations'; + +interface SaveTimelineButtonProps { + timelineId: string; + showOverlay: boolean; + toolTip?: string; + toggleSaveTimeline: () => void; + onSaveTimeline: SaveTimeline; + updateTitle: UpdateTitle; + updateDescription: UpdateDescription; +} + +interface TimelineTitleAndDescriptionProps { + timelineId: string; + toggleSaveTimeline: () => void; + onSaveTimeline: SaveTimeline; + updateTitle: UpdateTitle; + updateDescription: UpdateDescription; +} + +export const TimelineTitleAndDescription = React.memo( + ({ timelineId, toggleSaveTimeline, onSaveTimeline, updateTitle, updateDescription }) => { + const { savedObjectId, title, description, timelineType } = useShallowEqualSelector((state) => + timelineSelectors.selectTimeline(state, timelineId) + ); + + const handleClick = useCallback(() => { + toggleSaveTimeline(); + onSaveTimeline({ id: timelineId }); + }, [toggleSaveTimeline, onSaveTimeline, timelineId]); + + return ( + <> + + {savedObjectId == null ? i18n.SAVE_TIMELINE : i18n.NAME_TIMELINE} + + + + + + + + + + + + + + + + + + + + {savedObjectId == null ? i18n.DISCARD_TIMELINE : i18n.CLOSE_SAVE_TIMELINE} + + + + + {savedObjectId == null ? i18n.SAVE_TIMELINE : i18n.SAVE} + + + + + + + ); + } +); + +TimelineTitleAndDescription.displayName = 'TimelineTitleAndDescription'; + +const SaveTimelineComponent = React.memo( + ({ + timelineId, + showOverlay, + toggleSaveTimeline, + onSaveTimeline, + updateTitle, + updateDescription, + }) => ( + <> + + {timelineId == null ? i18n.SAVE_TIMELINE : i18n.UPDATE_TIMELINE} + + + {showOverlay ? ( + + + { + + } + + + ) : null} + + ) +); +SaveTimelineComponent.displayName = 'SaveTimelineComponent'; + +export const SaveTimelineButton = React.memo( + ({ toolTip, ...saveTimelineButtonProps }) => + saveTimelineButtonProps.showOverlay ? ( + + ) : ( + + + + ) +); +SaveTimelineButton.displayName = 'SaveTimelineButton'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts index 89ad11d75cae1..2853ad73db054 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts @@ -21,3 +21,35 @@ export const CALL_OUT_IMMUTABLE = i18n.translate( 'This prebuilt timeline template cannot be modified. To make changes, please duplicate this template and make modifications to the duplicate template.', } ); + +export const SAVE_TIMELINE = i18n.translate( + 'xpack.securitySolution.timeline.saveTimeline.modal.header', + { + defaultMessage: 'Save Timeline', + } +); + +export const SAVE = i18n.translate('xpack.securitySolution.timeline.nameTimeline.modal.header', { + defaultMessage: 'Save', +}); + +export const NAME_TIMELINE = i18n.translate( + 'xpack.securitySolution.timeline.nameTimeline.modal.header', + { + defaultMessage: 'Name Timeline', + } +); + +export const DISCARD_TIMELINE = i18n.translate( + 'xpack.securitySolution.timeline.saveTimeline.modal.discard.title', + { + defaultMessage: 'Discard Timeline', + } +); + +export const CLOSE_SAVE_TIMELINE = i18n.translate( + 'xpack.securitySolution.timeline.saveTimeline.modal.close.title', + { + defaultMessage: 'Close', + } +); 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 a28f4240d3a2f..7b7f5ad081e4c 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 @@ -41,7 +41,14 @@ import { Notes } from '../../notes'; import { AssociateNote, UpdateNote } from '../../notes/helpers'; import { NOTES_PANEL_WIDTH } from './notes_size'; -import { ButtonContainer, DescriptionContainer, LabelText, NameField, StyledStar } from './styles'; +import { + ButtonContainer, + DescriptionContainer, + LabelText, + NameField, + NameWrapper, + StyledStar, +} from './styles'; import * as i18n from './translations'; import { setInsertTimeline, showTimeline } from '../../../store/timeline/actions'; import { useCreateTimelineButton } from './use_create_timeline'; @@ -66,8 +73,9 @@ type CreateTimeline = ({ timelineType?: TimelineTypeLiteral; }) => void; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; -type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; -type UpdateDescription = ({ id, description }: { id: string; description: string }) => void; +export type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; +export type UpdateDescription = ({ id, description }: { id: string; description: string }) => void; +export type SaveTimeline = ({ id }: { id: string }) => void; export const StarIcon = React.memo<{ isFavorite: boolean; @@ -108,8 +116,11 @@ interface DescriptionProps { export const Description = React.memo( ({ description, timelineId, updateDescription }) => ( - - + + ( spellCheck={true} value={description} /> - - + + ) ); Description.displayName = 'Description'; @@ -130,29 +141,39 @@ interface NameProps { timelineType: TimelineType; title: string; updateTitle: UpdateTitle; + width?: string; + marginRight?: number; } -export const Name = React.memo(({ timelineId, timelineType, title, updateTitle }) => { - const handleChange = useCallback((e) => updateTitle({ id: timelineId, title: e.target.value }), [ - timelineId, - updateTitle, - ]); +export const Name = React.memo( + ({ timelineId, timelineType, title, updateTitle, width, marginRight }) => { + const handleChange = useCallback( + (e) => updateTitle({ id: timelineId, title: e.target.value }), + [timelineId, updateTitle] + ); - return ( - - - - ); -}); + return ( + + + + + + ); + } +); Name.displayName = 'Name'; interface NewCaseProps { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx index 9eea95a0a9b1a..bfd620fa971a1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx @@ -17,6 +17,7 @@ import { TimelineProperties } from './styles'; import { PropertiesRight } from './properties_right'; import { PropertiesLeft } from './properties_left'; import { useAllCasesModal } from '../../../../cases/components/use_all_cases_modal'; +import { SaveTimeline } from './helpers'; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; @@ -35,6 +36,7 @@ interface Props { timelineId: string; timelineType: TimelineTypeLiteral; status: TimelineStatusLiteral; + saveTimeline: SaveTimeline; title: string; toggleLock: ToggleLock; updateDescription: UpdateDescription; @@ -66,6 +68,7 @@ export const Properties = React.memo( isDatepickerLocked, isFavorite, noteIds, + saveTimeline, status, timelineId, timelineType, @@ -81,6 +84,7 @@ export const Properties = React.memo( const [showActions, setShowActions] = useState(false); const [showNotes, setShowNotes] = useState(false); const [showTimelineModal, setShowTimelineModal] = useState(false); + const [showSaveTimelineOverlay, setShowSaveTimelineOverlay] = useState(false); const onButtonClick = useCallback(() => setShowActions(!showActions), [showActions]); const onToggleShowNotes = useCallback(() => setShowNotes(!showNotes), [showNotes]); @@ -92,6 +96,10 @@ export const Properties = React.memo( setShowTimelineModal(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const onToggleSaveTimeline = useCallback( + () => setShowSaveTimelineOverlay(!showSaveTimelineOverlay), + [setShowSaveTimelineOverlay, showSaveTimelineOverlay] + ); const { Modal: AllCasesModal, onOpenModal: onOpenCaseModal } = useAllCasesModal({ timelineId }); const datePickerWidth = useMemo( @@ -119,10 +127,13 @@ export const Properties = React.memo( isFavorite={isFavorite} noteIds={noteIds} onToggleShowNotes={onToggleShowNotes} + onToggleSaveTimeline={onToggleSaveTimeline} status={status} + saveTimeline={saveTimeline} showDescription={width >= showDescriptionThreshold} showNotes={showNotes} showNotesFromWidth={width >= showNotesThreshold} + showSaveTimelineOverlay={showSaveTimelineOverlay} timelineId={timelineId} timelineType={timelineType} title={title} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx index a3cd8802c36bc..5b7c68aa356fc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx @@ -8,7 +8,7 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/e import React from 'react'; import styled from 'styled-components'; -import { Description, Name, NotesButton, StarIcon } from './helpers'; +import { Description, Name, NotesButton, SaveTimeline, StarIcon } from './helpers'; import { AssociateNote, UpdateNote } from '../../notes/helpers'; import { Note } from '../../../../common/lib/note'; @@ -16,6 +16,7 @@ import { SuperDatePicker } from '../../../../common/components/super_date_picker import { TimelineTypeLiteral, TimelineStatusLiteral } from '../../../../../common/types/timeline'; import * as i18n from './translations'; +import { SaveTimelineButton } from '../header/save_timeline'; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; @@ -33,8 +34,10 @@ interface Props { updateDescription: UpdateDescription; showNotes: boolean; status: TimelineStatusLiteral; + saveTimeline: SaveTimeline; associateNote: AssociateNote; showNotesFromWidth: boolean; + showSaveTimelineOverlay: boolean; getNotesByIds: (noteIds: string[]) => Note[]; onToggleShowNotes: () => void; noteIds: string[]; @@ -42,6 +45,7 @@ interface Props { isDatepickerLocked: boolean; toggleLock: () => void; datePickerWidth: number; + onToggleSaveTimeline: () => void; } export const PropertiesLeftStyle = styled(EuiFlexGroup)` @@ -80,13 +84,16 @@ export const PropertiesLeft = React.memo( updateIsFavorite, showDescription, description, + onToggleSaveTimeline, title, timelineType, updateTitle, updateDescription, status, + saveTimeline, showNotes, showNotesFromWidth, + showSaveTimelineOverlay, associateNote, getNotesByIds, noteIds, @@ -122,6 +129,15 @@ export const PropertiesLeft = React.memo( ) : null} + + {showNotesFromWidth ? ( (({ width }) => ({ `; DatePicker.displayName = 'DatePicker'; -export const NameField = styled(EuiFieldText)` - width: 150px; - margin-right: 5px; +export const NameField = styled(({ width, marginRight, ...rest }) => )` + width: ${({ width = '150px' }) => width}; + margin-right: ${({ marginRight = 10 }) => marginRight} px; + + .euiToolTipAnchor { + display: block; + } `; NameField.displayName = 'NameField'; +export const NameWrapper = styled.div` + .euiToolTipAnchor { + display: block; + } +`; +NameWrapper.displayName = 'NameWrapper'; + export const DescriptionContainer = styled.div` animation: ${fadeInEffect} 0.3s; margin-right: 5px; min-width: 150px; + + .euiToolTipAnchor { + display: block; + } `; DescriptionContainer.displayName = 'DescriptionContainer'; From 75b0f655603a926507396e4852009baf7b20acd1 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Wed, 21 Oct 2020 18:42:09 +0100 Subject: [PATCH 02/16] disable auto save --- .../security_solution/common/constants.ts | 2 + .../components/flyout/header/index.tsx | 24 ++++-- .../timeline/header/save_timeline.tsx | 81 ++++++++++++++++--- .../timeline/header/translations.ts | 23 +++++- .../timeline/properties/helpers.tsx | 80 ++++++++++++++---- .../timeline/properties/properties_left.tsx | 19 +++-- .../components/timeline/properties/styles.tsx | 4 +- .../timeline/properties/translations.ts | 2 +- .../properties/use_create_timeline.tsx | 17 +++- .../timelines/store/timeline/actions.ts | 36 ++++++++- .../public/timelines/store/timeline/epic.ts | 5 +- .../timelines/store/timeline/reducer.ts | 2 +- 12 files changed, 240 insertions(+), 55 deletions(-) diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 2910f02a187f4..50df6b8cf88ac 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -179,3 +179,5 @@ export const showAllOthersBucket: string[] = [ 'destination.ip', 'user.name', ]; + +export const enableNewTimeline = false; 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 b4e4911213b6d..dce40525921a5 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 @@ -127,16 +127,30 @@ const makeMapStateToProps = () => { const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ associateNote: (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), - updateDescription: ({ id, description }: { id: string; description: string }) => - dispatch(timelineActions.updateDescription({ id, description })), + updateDescription: ({ + id, + description, + disableAutoSave, + }: { + id: string; + description: string; + disableAutoSave?: boolean; + }) => dispatch(timelineActions.updateDescription({ id, description, disableAutoSave })), updateIsFavorite: ({ id, isFavorite }: { id: string; isFavorite: boolean }) => dispatch(timelineActions.updateIsFavorite({ id, isFavorite })), updateNote: (note: Note) => dispatch(appActions.updateNote({ note })), - updateTitle: ({ id, title }: { id: string; title: string }) => - dispatch(timelineActions.updateTitle({ id, title })), + updateTitle: ({ + id, + title, + disableAutoSave, + }: { + id: string; + title: string; + disableAutoSave?: boolean; + }) => dispatch(timelineActions.updateTitle({ id, title, disableAutoSave })), toggleLock: ({ linkToId }: { linkToId: InputsModelId }) => dispatch(inputsActions.toggleTimelineLinkTo({ linkToId })), - saveTimeline: ({ id }: { id: string }) => dispatch(timelineActions.startTimelineSaving({ id })), + saveTimeline: (args) => dispatch(timelineActions.saveTimeline(args)), }); const connector = connect(makeMapStateToProps, mapDispatchToProps); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx index d7f79dcecef0c..81cda418f903e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx @@ -16,8 +16,11 @@ import { EuiOverlayMask, EuiSpacer, EuiToolTip, + EuiProgress, } from '@elastic/eui'; -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import styled from 'styled-components'; +import { TimelineType } from '../../../../../common/types/timeline'; import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; import { timelineSelectors } from '../../../../timelines/store/timeline'; import { @@ -29,6 +32,7 @@ import { } from '../properties/helpers'; import { NOTES_PANEL_WIDTH } from '../properties/notes_size'; import { TIMELINE_TITLE, DESCRIPTION } from '../properties/translations'; +import { useCreateTimelineButton } from '../properties/use_create_timeline'; import * as i18n from './translations'; interface SaveTimelineButtonProps { @@ -49,24 +53,63 @@ interface TimelineTitleAndDescriptionProps { updateDescription: UpdateDescription; } +const Wrapper = styled(EuiModalBody)` + .euiFormRow { + max-width: none; + } + + .euiFormControlLayout { + max-width: none; + } + + .euiFieldText { + max-width: none; + } +`; + +Wrapper.displayName = 'Wrapper'; + export const TimelineTitleAndDescription = React.memo( ({ timelineId, toggleSaveTimeline, onSaveTimeline, updateTitle, updateDescription }) => { - const { savedObjectId, title, description, timelineType } = useShallowEqualSelector((state) => + const timelineToCreate = useShallowEqualSelector((state) => timelineSelectors.selectTimeline(state, timelineId) ); + const { description, isSaving, savedObjectId, title, timelineType } = timelineToCreate; const handleClick = useCallback(() => { - toggleSaveTimeline(); - onSaveTimeline({ id: timelineId }); - }, [toggleSaveTimeline, onSaveTimeline, timelineId]); + onSaveTimeline({ ...timelineToCreate, id: timelineId }); + }, [onSaveTimeline, timelineToCreate, timelineId]); + + const { getButton } = useCreateTimelineButton({ timelineId, timelineType }); + + const discardTimelineButton = useMemo( + () => + getButton({ + title: + timelineType === TimelineType.template + ? i18n.DISCARD_TIMELINE_TEMPLATE + : i18n.DISCARD_TIMELINE, + outline: true, + iconType: '', + fill: false, + }), + [getButton, timelineType] + ); return ( <> + {isSaving && } - {savedObjectId == null ? i18n.SAVE_TIMELINE : i18n.NAME_TIMELINE} + {savedObjectId == null + ? timelineType === TimelineType.template + ? i18n.SAVE_TIMELINE_TEMPLATE + : i18n.SAVE_TIMELINE + : timelineType === TimelineType.template + ? i18n.NAME_TIMELINE_TEMPLATE + : i18n.NAME_TIMELINE} - + @@ -86,6 +130,9 @@ export const TimelineTitleAndDescription = React.memo @@ -93,18 +140,26 @@ export const TimelineTitleAndDescription = React.memo - - {savedObjectId == null ? i18n.DISCARD_TIMELINE : i18n.CLOSE_SAVE_TIMELINE} - + {savedObjectId == null ? ( + discardTimelineButton + ) : ( + + {i18n.CLOSE_MODAL} + + )} - {savedObjectId == null ? i18n.SAVE_TIMELINE : i18n.SAVE} + {savedObjectId == null + ? timelineType === TimelineType.template + ? i18n.SAVE_TIMELINE_TEMPLATE + : i18n.SAVE_TIMELINE + : i18n.SAVE} - + ); } @@ -123,7 +178,7 @@ const SaveTimelineComponent = React.memo( }) => ( <> - {timelineId == null ? i18n.SAVE_TIMELINE : i18n.UPDATE_TIMELINE} + {timelineId == null ? i18n.SAVE_TIMELINE : i18n.NAME_TIMELINE} {showOverlay ? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts index 2853ad73db054..c9c4a60970887 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts @@ -29,6 +29,13 @@ export const SAVE_TIMELINE = i18n.translate( } ); +export const SAVE_TIMELINE_TEMPLATE = i18n.translate( + 'xpack.securitySolution.timeline.saveTimelineTemplate.modal.header', + { + defaultMessage: 'Save Timeline Template', + } +); + export const SAVE = i18n.translate('xpack.securitySolution.timeline.nameTimeline.modal.header', { defaultMessage: 'Save', }); @@ -40,6 +47,13 @@ export const NAME_TIMELINE = i18n.translate( } ); +export const NAME_TIMELINE_TEMPLATE = i18n.translate( + 'xpack.securitySolution.timeline.nameTimelineTemplate.modal.header', + { + defaultMessage: 'Name Timeline Template', + } +); + export const DISCARD_TIMELINE = i18n.translate( 'xpack.securitySolution.timeline.saveTimeline.modal.discard.title', { @@ -47,7 +61,14 @@ export const DISCARD_TIMELINE = i18n.translate( } ); -export const CLOSE_SAVE_TIMELINE = i18n.translate( +export const DISCARD_TIMELINE_TEMPLATE = i18n.translate( + 'xpack.securitySolution.timeline.saveTimelineTemplate.modal.discard.title', + { + defaultMessage: 'Discard Timeline Template', + } +); + +export const CLOSE_MODAL = i18n.translate( 'xpack.securitySolution.timeline.saveTimeline.modal.close.title', { defaultMessage: 'Close', 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 7b7f5ad081e4c..04b5b988a3087 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 @@ -16,6 +16,7 @@ import { EuiModal, EuiOverlayMask, EuiToolTip, + EuiTextArea, } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import uuid from 'uuid'; @@ -73,8 +74,24 @@ type CreateTimeline = ({ timelineType?: TimelineTypeLiteral; }) => void; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; -export type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; -export type UpdateDescription = ({ id, description }: { id: string; description: string }) => void; +export type UpdateTitle = ({ + id, + title, + disableAutoSave, +}: { + id: string; + title: string; + disableAutoSave?: boolean; +}) => void; +export type UpdateDescription = ({ + id, + description, + disableAutoSave, +}: { + id: string; + description: string; + disableAutoSave?: boolean; +}) => void; export type SaveTimeline = ({ id }: { id: string }) => void; export const StarIcon = React.memo<{ @@ -112,24 +129,46 @@ interface DescriptionProps { description: string; timelineId: string; updateDescription: UpdateDescription; + isTextArea?: boolean; + disableAutoSave?: boolean; + marginRight?: number; } export const Description = React.memo( - ({ description, timelineId, updateDescription }) => ( - + ({ + description, + timelineId, + updateDescription, + isTextArea = false, + disableAutoSave = false, + marginRight, + }) => ( + - updateDescription({ id: timelineId, description: e.target.value })} - placeholder={i18n.DESCRIPTION} - spellCheck={true} - value={description} - /> + {isTextArea ? ( + + updateDescription({ id: timelineId, description: e.target.value, disableAutoSave }) + } + placeholder={i18n.DESCRIPTION} + value={description} + /> + ) : ( + updateDescription({ id: timelineId, description: e.target.value })} + placeholder={i18n.DESCRIPTION} + spellCheck={true} + value={description} + /> + )} ) @@ -143,13 +182,22 @@ interface NameProps { updateTitle: UpdateTitle; width?: string; marginRight?: number; + disableAutoSave?: boolean; } export const Name = React.memo( - ({ timelineId, timelineType, title, updateTitle, width, marginRight }) => { + ({ + timelineId, + timelineType, + title, + updateTitle, + width, + marginRight, + disableAutoSave = false, + }) => { const handleChange = useCallback( - (e) => updateTitle({ id: timelineId, title: e.target.value }), - [timelineId, updateTitle] + (e) => updateTitle({ id: timelineId, title: e.target.value, disableAutoSave }), + [timelineId, updateTitle, disableAutoSave] ); return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx index 5b7c68aa356fc..7e2f9440ec4d3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx @@ -17,6 +17,7 @@ import { TimelineTypeLiteral, TimelineStatusLiteral } from '../../../../../commo import * as i18n from './translations'; import { SaveTimelineButton } from '../header/save_timeline'; +import { enableNewTimeline } from '../../../../../common/constants'; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; @@ -129,14 +130,16 @@ export const PropertiesLeft = React.memo( ) : null} - + {enableNewTimeline && ( + + )} {showNotesFromWidth ? ( 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 3151c55979472..e4504d40bc0a7 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 @@ -57,9 +57,9 @@ export const NameWrapper = styled.div` `; NameWrapper.displayName = 'NameWrapper'; -export const DescriptionContainer = styled.div` +export const DescriptionContainer = styled.div<{ marginRight?: number }>` animation: ${fadeInEffect} 0.3s; - margin-right: 5px; + margin-right: ${({ marginRight = 5 }) => marginRight}px; min-width: 150px; .euiToolTipAnchor { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts index 1fc3b7b00f847..3fc1fdec9450a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts @@ -34,7 +34,7 @@ export const NOT_A_FAVORITE = i18n.translate( export const TIMELINE_TITLE = i18n.translate( 'xpack.securitySolution.timeline.properties.timelineTitleAriaLabel', { - defaultMessage: 'Timeline title', + defaultMessage: 'Title', } ); 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 28dd865c763ae..2bfaaa072d3b7 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 @@ -93,15 +93,26 @@ export const useCreateTimelineButton = ({ }, [createTimeline, timelineId, timelineType, closeGearMenu]); const getButton = useCallback( - ({ outline, title }: { outline?: boolean; title?: string }) => { + ({ + outline, + title, + iconType = 'plusInCircle', + fill = true, + }: { + outline?: boolean; + title?: string; + iconType?: string; + fill?: boolean; + }) => { const buttonProps = { - iconType: 'plusInCircle', + iconType, onClick: handleButtonClick, + fill, }; const dataTestSubjPrefix = timelineType === TimelineType.template ? `template-timeline-new` : `timeline-new`; return outline ? ( - + {title} ) : ( diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index 472e82426468e..07df64bbd1608 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -56,6 +56,30 @@ export const applyDeltaToColumnWidth = actionCreator<{ delta: number; }>('APPLY_DELTA_TO_COLUMN_WIDTH'); +export const saveTimeline = actionCreator<{ + id: string; + dataProviders?: DataProvider[]; + dateRange?: { + start: string; + end: string; + }; + excludedRowRendererIds?: RowRendererId[]; + filters?: Filter[]; + columns: ColumnHeaderOptions[]; + itemsPerPage?: number; + indexNames: string[]; + kqlQuery?: { + filterQuery: SerializedFilterQuery | null; + filterQueryDraft: KueryFilterQuery | null; + }; + show?: boolean; + sort?: Sort; + showCheckboxes?: boolean; + timelineType?: TimelineTypeLiteral; + templateTimelineId?: string; + templateTimelineVersion?: number; +}>('SAVE_TIMELINE'); + export const createTimeline = actionCreator<{ id: string; dataProviders?: DataProvider[]; @@ -174,9 +198,11 @@ export const updateHighlightedDropAndProviderId = actionCreator<{ providerId: string; }>('UPDATE_DROP_AND_PROVIDER'); -export const updateDescription = actionCreator<{ id: string; description: string }>( - 'UPDATE_DESCRIPTION' -); +export const updateDescription = actionCreator<{ + id: string; + description: string; + disableAutoSave?: boolean; +}>('UPDATE_DESCRIPTION'); export const updateKqlMode = actionCreator<{ id: string; kqlMode: KqlMode }>('UPDATE_KQL_MODE'); @@ -205,7 +231,9 @@ export const updateItemsPerPageOptions = actionCreator<{ itemsPerPageOptions: number[]; }>('UPDATE_ITEMS_PER_PAGE_OPTIONS'); -export const updateTitle = actionCreator<{ id: string; title: string }>('UPDATE_TITLE'); +export const updateTitle = actionCreator<{ id: string; title: string; disableAutoSave?: boolean }>( + 'UPDATE_TITLE' +); export const updatePageIndex = actionCreator<{ id: string; activePage: number }>( 'UPDATE_PAGE_INDEX' diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts index cc8e856de1b16..9aca7501792b3 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts @@ -78,6 +78,7 @@ import { createTimeline, addTimeline, showCallOutUnauthorizedMsg, + saveTimeline, } from './actions'; import { ColumnHeaderOptions, TimelineModel } from './model'; import { epicPersistNote, timelineNoteActionsType } from './epic_note'; @@ -95,6 +96,7 @@ const timelineActionsType = [ dataProviderEdited.type, removeColumn.type, removeProvider.type, + saveTimeline.type, setExcludedRowRendererIds.type, setFilters.type, setSavedQueryId.type, @@ -179,7 +181,8 @@ export const createTimelineEpic = (): Epic< } else if ( timelineActionsType.includes(action.type) && !timelineObj.isLoading && - isItAtimelineAction(timelineId) + isItAtimelineAction(timelineId) && + !action.payload?.disableAutoSave ) { return true; } diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index 1d956e02e7083..7c227f1c80610 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -389,7 +389,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: updateTimelineKqlMode({ id, kqlMode, timelineById: state.timelineById }), })) - .case(updateTitle, (state, { id, title }) => ({ + .case(updateTitle, (state, { id, title, disableAutoSave }) => ({ ...state, timelineById: updateTimelineTitle({ id, title, timelineById: state.timelineById }), })) From 16e35a5aa95db99298e55f05b9f8ea181574230b Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 22 Oct 2020 15:27:27 +0100 Subject: [PATCH 03/16] unit test --- .../timeline/header/save_timeline.test.tsx | 270 ++++++++++++++++++ .../timeline/header/save_timeline.tsx | 84 +++--- 2 files changed, 319 insertions(+), 35 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.test.tsx diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.test.tsx new file mode 100644 index 0000000000000..32916d875b496 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.test.tsx @@ -0,0 +1,270 @@ +/* + * 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 { shallow } from 'enzyme'; +import { + SaveTimelineButton, + SaveTimelineComponent, + TimelineTitleAndDescription, +} from './save_timeline'; +import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { useCreateTimelineButton } from '../properties/use_create_timeline'; +import { TimelineType } from '../../../../../common/types/timeline'; +import * as i18n from './translations'; + +jest.mock('../../../../common/hooks/use_selector', () => ({ + useShallowEqualSelector: jest.fn(), +})); + +jest.mock('../../../../timelines/store/timeline', () => ({ + timelineSelectors: { + selectTimeline: jest.fn(), + }, +})); + +jest.mock('../properties/use_create_timeline', () => ({ + useCreateTimelineButton: jest.fn(), +})); + +describe('TimelineTitleAndDescription', () => { + describe('save timeline', () => { + const props = { + timelineId: 'timeline-1', + toggleSaveTimeline: jest.fn(), + onSaveTimeline: jest.fn(), + updateTitle: jest.fn(), + updateDescription: jest.fn(), + }; + + const mockGetButton = jest.fn().mockReturnValue(
); + + beforeEach(() => { + (useShallowEqualSelector as jest.Mock).mockReturnValue({ + description: '', + isSaving: true, + savedObjectId: null, + title: 'my timeline', + timelineType: TimelineType.default, + }); + (useCreateTimelineButton as jest.Mock).mockReturnValue({ + getButton: mockGetButton, + }); + }); + + afterEach(() => { + (useShallowEqualSelector as jest.Mock).mockReset(); + (useCreateTimelineButton as jest.Mock).mockReset(); + mockGetButton.mockClear(); + }); + + test('show proress bar while saving', () => { + const component = shallow(); + expect(component.find('[data-test-subj="progress-bar"]').exists()).toEqual(true); + }); + + test('Show correct header for save timeline modal', () => { + const component = shallow(); + expect(component.find('[data-test-subj="modal-header"]').prop('children')).toEqual( + i18n.SAVE_TIMELINE + ); + }); + + test('Show correct header for save timeline template modal', () => { + (useShallowEqualSelector as jest.Mock).mockReturnValue({ + description: '', + isSaving: true, + savedObjectId: null, + title: 'my timeline', + timelineType: TimelineType.template, + }); + const component = shallow(); + expect(component.find('[data-test-subj="modal-header"]').prop('children')).toEqual( + i18n.SAVE_TIMELINE_TEMPLATE + ); + }); + + test('Show name field', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-name"]').exists()).toEqual(true); + }); + + test('Show description field', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-description"]').exists()).toEqual(true); + }); + + test('Show discardTimelineButton', () => { + const component = shallow(); + expect(component.find('[data-test-subj="mock-discard-button"]').exists()).toEqual(true); + }); + + test('get discardTimelineButton with correct props', () => { + shallow(); + expect(mockGetButton).toBeCalledWith({ + title: i18n.DISCARD_TIMELINE, + outline: true, + iconType: '', + fill: false, + }); + }); + + test('get discardTimelineTemplateButton with correct props', () => { + (useShallowEqualSelector as jest.Mock).mockReturnValue({ + description: 'xxxx', + isSaving: true, + savedObjectId: null, + title: 'my timeline', + timelineType: TimelineType.template, + }); + shallow(); + expect(mockGetButton).toBeCalledWith({ + title: i18n.DISCARD_TIMELINE_TEMPLATE, + outline: true, + iconType: '', + fill: false, + }); + }); + + test('Show saveButton', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-button"]').exists()).toEqual(true); + }); + }); + + describe('update timeline', () => { + const props = { + timelineId: 'timeline-1', + toggleSaveTimeline: jest.fn(), + onSaveTimeline: jest.fn(), + updateTitle: jest.fn(), + updateDescription: jest.fn(), + }; + + const mockGetButton = jest.fn().mockReturnValue(
); + + beforeEach(() => { + (useShallowEqualSelector as jest.Mock).mockReturnValue({ + description: 'xxxx', + isSaving: true, + savedObjectId: '1234', + title: 'my timeline', + timelineType: TimelineType.default, + }); + (useCreateTimelineButton as jest.Mock).mockReturnValue({ + getButton: mockGetButton, + }); + }); + + afterEach(() => { + (useShallowEqualSelector as jest.Mock).mockReset(); + (useCreateTimelineButton as jest.Mock).mockReset(); + mockGetButton.mockClear(); + }); + + test('show proress bar while saving', () => { + const component = shallow(); + expect(component.find('[data-test-subj="progress-bar"]').exists()).toEqual(true); + }); + + test('Show correct header for save timeline modal', () => { + const component = shallow(); + expect(component.find('[data-test-subj="modal-header"]').prop('children')).toEqual( + i18n.NAME_TIMELINE + ); + }); + + test('Show correct header for save timeline template modal', () => { + (useShallowEqualSelector as jest.Mock).mockReturnValue({ + description: 'xxxx', + isSaving: true, + savedObjectId: '1234', + title: 'my timeline', + timelineType: TimelineType.template, + }); + const component = shallow(); + expect(component.find('[data-test-subj="modal-header"]').prop('children')).toEqual( + i18n.NAME_TIMELINE_TEMPLATE + ); + }); + + test('Show name field', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-name"]').exists()).toEqual(true); + }); + + test('Show description field', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-description"]').exists()).toEqual(true); + }); + + test('Show discardTimelineButton', () => { + const component = shallow(); + expect(component.find('[data-test-subj="mock-discard-button"]').exists()).toEqual(false); + }); + + test('Show saveButton', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-button"]').exists()).toEqual(true); + }); + }); +}); + +describe('SaveTimelineComponent', () => { + const props = { + timelineId: 'timeline-1', + showOverlay: false, + toggleSaveTimeline: jest.fn(), + onSaveTimeline: jest.fn(), + updateTitle: jest.fn(), + updateDescription: jest.fn(), + }; + test('should show a button with pencil icon', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-button-icon"]').prop('iconType')).toEqual( + 'pencil' + ); + }); + + test('should show a modal when showOverlay equals true', () => { + const testProps = { + ...props, + showOverlay: true, + }; + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-modal"]').exists()).toEqual(true); + }); + + test('should not show a modal when showOverlay equals false', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-modal"]').exists()).toEqual(false); + }); +}); + +describe('SaveTimelineButton', () => { + const props = { + timelineId: 'timeline-1', + showOverlay: false, + toolTip: 'tooltip message', + toggleSaveTimeline: jest.fn(), + onSaveTimeline: jest.fn(), + updateTitle: jest.fn(), + updateDescription: jest.fn(), + }; + test('Show tooltip', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(true); + }); + + test('Hide tooltip', () => { + const testProps = { + ...props, + showOverlay: true, + }; + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(false); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx index 81cda418f903e..855af3c68b0ef 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx @@ -71,14 +71,15 @@ Wrapper.displayName = 'Wrapper'; export const TimelineTitleAndDescription = React.memo( ({ timelineId, toggleSaveTimeline, onSaveTimeline, updateTitle, updateDescription }) => { - const timelineToCreate = useShallowEqualSelector((state) => + const timeline = useShallowEqualSelector((state) => timelineSelectors.selectTimeline(state, timelineId) ); - const { description, isSaving, savedObjectId, title, timelineType } = timelineToCreate; + + const { description, isSaving, savedObjectId, title, timelineType } = timeline; const handleClick = useCallback(() => { - onSaveTimeline({ ...timelineToCreate, id: timelineId }); - }, [onSaveTimeline, timelineToCreate, timelineId]); + onSaveTimeline({ ...timeline, id: timelineId }); + }, [onSaveTimeline, timeline, timelineId]); const { getButton } = useCreateTimelineButton({ timelineId, timelineType }); @@ -96,18 +97,28 @@ export const TimelineTitleAndDescription = React.memo - {isSaving && } - - {savedObjectId == null - ? timelineType === TimelineType.template - ? i18n.SAVE_TIMELINE_TEMPLATE - : i18n.SAVE_TIMELINE - : timelineType === TimelineType.template - ? i18n.NAME_TIMELINE_TEMPLATE - : i18n.NAME_TIMELINE} - + {isSaving && ( + + )} + {modalHeader} @@ -120,6 +131,7 @@ export const TimelineTitleAndDescription = React.memo @@ -133,6 +145,7 @@ export const TimelineTitleAndDescription = React.memo @@ -149,12 +162,13 @@ export const TimelineTitleAndDescription = React.memo - - {savedObjectId == null - ? timelineType === TimelineType.template - ? i18n.SAVE_TIMELINE_TEMPLATE - : i18n.SAVE_TIMELINE - : i18n.SAVE} + + {saveButtonTitle} @@ -167,7 +181,7 @@ export const TimelineTitleAndDescription = React.memo( +export const SaveTimelineComponent = React.memo( ({ timelineId, showOverlay, @@ -177,26 +191,26 @@ const SaveTimelineComponent = React.memo( updateDescription, }) => ( <> - - {timelineId == null ? i18n.SAVE_TIMELINE : i18n.NAME_TIMELINE} - + {showOverlay ? ( - { - - } + ) : null} @@ -210,7 +224,7 @@ export const SaveTimelineButton = React.memo( saveTimelineButtonProps.showOverlay ? ( ) : ( - + ) From 952c036cc625a6d026876eb347143be90cea0bbe Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 22 Oct 2020 16:27:44 +0100 Subject: [PATCH 04/16] fix type error --- .../components/flyout/header/index.tsx | 3 +- .../timeline/header/save_timeline.tsx | 5 ++- .../timeline/properties/helpers.tsx | 4 +-- .../timeline/properties/index.test.tsx | 1 + .../timelines/store/timeline/actions.ts | 34 ++++--------------- .../public/timelines/store/timeline/epic.ts | 3 +- 6 files changed, 17 insertions(+), 33 deletions(-) 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 dce40525921a5..d7b5c3e0f4f6b 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 @@ -20,6 +20,7 @@ import { timelineActions, timelineSelectors } from '../../../store/timeline'; import { TimelineModel } from '../../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; import { InputsModelId } from '../../../../common/store/inputs/constants'; +import { TimelineInput } from '../../../store/timeline/actions'; interface OwnProps { timelineId: string; @@ -150,7 +151,7 @@ const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ }) => dispatch(timelineActions.updateTitle({ id, title, disableAutoSave })), toggleLock: ({ linkToId }: { linkToId: InputsModelId }) => dispatch(inputsActions.toggleTimelineLinkTo({ linkToId })), - saveTimeline: (args) => dispatch(timelineActions.saveTimeline(args)), + saveTimeline: (args: TimelineInput) => dispatch(timelineActions.saveTimeline(args)), }); const connector = connect(makeMapStateToProps, mapDispatchToProps); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx index 855af3c68b0ef..4281b5b528ad6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx @@ -78,7 +78,10 @@ export const TimelineTitleAndDescription = React.memo { - onSaveTimeline({ ...timeline, id: timelineId }); + onSaveTimeline({ + ...timeline, + id: timelineId, + }); }, [onSaveTimeline, timeline, timelineId]); const { getButton } = useCreateTimelineButton({ timelineId, timelineType }); 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 04b5b988a3087..fd0e23901bc9c 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 @@ -51,7 +51,7 @@ import { StyledStar, } from './styles'; import * as i18n from './translations'; -import { setInsertTimeline, showTimeline } from '../../../store/timeline/actions'; +import { setInsertTimeline, showTimeline, TimelineInput } from '../../../store/timeline/actions'; import { useCreateTimelineButton } from './use_create_timeline'; export const historyToolTip = 'The chronological history of actions related to this timeline'; @@ -92,7 +92,7 @@ export type UpdateDescription = ({ description: string; disableAutoSave?: boolean; }) => void; -export type SaveTimeline = ({ id }: { id: string }) => void; +export type SaveTimeline = (args: TimelineInput) => void; export const StarIcon = React.memo<{ isFavorite: boolean; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx index 19344a7fd7c9b..cdedca23e85af 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx @@ -92,6 +92,7 @@ const defaultProps = { description: '', getNotesByIds: jest.fn(), noteIds: [], + saveTimeline: jest.fn(), status: TimelineStatus.active, timelineId: 'abc', toggleLock: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index 07df64bbd1608..c066de8af9f20 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -56,7 +56,7 @@ export const applyDeltaToColumnWidth = actionCreator<{ delta: number; }>('APPLY_DELTA_TO_COLUMN_WIDTH'); -export const saveTimeline = actionCreator<{ +export interface TimelineInput { id: string; dataProviders?: DataProvider[]; dateRange?: { @@ -76,33 +76,13 @@ export const saveTimeline = actionCreator<{ sort?: Sort; showCheckboxes?: boolean; timelineType?: TimelineTypeLiteral; - templateTimelineId?: string; - templateTimelineVersion?: number; -}>('SAVE_TIMELINE'); + templateTimelineId?: string | null; + templateTimelineVersion?: number | null; +} -export const createTimeline = actionCreator<{ - id: string; - dataProviders?: DataProvider[]; - dateRange?: { - start: string; - end: string; - }; - excludedRowRendererIds?: RowRendererId[]; - filters?: Filter[]; - columns: ColumnHeaderOptions[]; - itemsPerPage?: number; - indexNames: string[]; - kqlQuery?: { - filterQuery: SerializedFilterQuery | null; - filterQueryDraft: KueryFilterQuery | null; - }; - show?: boolean; - sort?: Sort; - showCheckboxes?: boolean; - timelineType?: TimelineTypeLiteral; - templateTimelineId?: string; - templateTimelineVersion?: number; -}>('CREATE_TIMELINE'); +export const saveTimeline = actionCreator('SAVE_TIMELINE'); + +export const createTimeline = actionCreator('CREATE_TIMELINE'); export const pinEvent = actionCreator<{ id: string; eventId: string }>('PIN_EVENT'); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts index 9aca7501792b3..d50de33412175 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts @@ -182,11 +182,10 @@ export const createTimelineEpic = (): Epic< timelineActionsType.includes(action.type) && !timelineObj.isLoading && isItAtimelineAction(timelineId) && - !action.payload?.disableAutoSave + !get('payload.disableAutoSave', action) ) { return true; } - return false; }), debounceTime(500), mergeMap(([action]) => { From 64835973d7fbab1042144ea0b462414beb714aba Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 22 Oct 2020 16:59:02 +0100 Subject: [PATCH 05/16] update translation --- .../public/timelines/components/timeline/header/translations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts index c9c4a60970887..37783d927f103 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts @@ -36,7 +36,7 @@ export const SAVE_TIMELINE_TEMPLATE = i18n.translate( } ); -export const SAVE = i18n.translate('xpack.securitySolution.timeline.nameTimeline.modal.header', { +export const SAVE = i18n.translate('xpack.securitySolution.timeline.nameTimeline.save.title', { defaultMessage: 'Save', }); From 0e8dc517835de41db42e736e9685cdb0203403ed Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 22 Oct 2020 19:10:14 +0100 Subject: [PATCH 06/16] add unit tests --- .../timeline/properties/helpers.test.tsx | 73 ++++++++++++++++++- .../timeline/properties/helpers.tsx | 67 +++++++++-------- .../properties/use_create_timeline.test.tsx | 40 ++++++++++ 3 files changed, 149 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx index 887c2e1e825f8..dd0695e795397 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx @@ -5,8 +5,10 @@ */ import React from 'react'; import { mount, shallow } from 'enzyme'; -import { NewTimeline, NewTimelineProps } from './helpers'; +import { Description, Name, NewTimeline, NewTimelineProps } from './helpers'; import { useCreateTimelineButton } from './use_create_timeline'; +import * as i18n from './translations'; +import { TimelineType } from '../../../../../common/types/timeline'; jest.mock('./use_create_timeline', () => ({ useCreateTimelineButton: jest.fn(), @@ -83,3 +85,72 @@ describe('NewTimeline', () => { }); }); }); + +describe('Description', () => { + const props = { + description: 'xxx', + timelineId: 'timeline-1', + updateDescription: jest.fn(), + }; + + test('should render tooltip', () => { + const component = shallow(); + expect( + component.find('[data-test-subj="timeline-description-tool-tip"]').prop('content') + ).toEqual(i18n.DESCRIPTION_TOOL_TIP); + }); + + test('should not render textarea if isTextArea is false', () => { + const component = shallow(); + expect(component.find('[data-test-subj="timeline-description-textarea"]').exists()).toEqual( + false + ); + + expect(component.find('[data-test-subj="timeline-description"]').exists()).toEqual(true); + }); + + test('should render textarea if isTextArea is true', () => { + const testProps = { + ...props, + isTextArea: true, + }; + const component = shallow(); + expect(component.find('[data-test-subj="timeline-description-textarea"]').exists()).toEqual( + true + ); + }); +}); + +describe('Name', () => { + const props = { + timelineId: 'timeline-1', + timelineType: TimelineType.default, + title: 'xxx', + updateTitle: jest.fn(), + }; + + test('should render tooltip', () => { + const component = shallow(); + expect(component.find('[data-test-subj="timeline-title-tool-tip"]').prop('content')).toEqual( + i18n.TITLE + ); + }); + + test('should render placeholder by timelineType - timeline', () => { + const component = shallow(); + expect(component.find('[data-test-subj="timeline-title"]').prop('placeholder')).toEqual( + i18n.UNTITLED_TIMELINE + ); + }); + + test('should render placeholder by timelineType - timeline template', () => { + const testProps = { + ...props, + timelineType: TimelineType.template, + }; + const component = shallow(); + expect(component.find('[data-test-subj="timeline-title"]').prop('placeholder')).toEqual( + i18n.UNTITLED_TEMPLATE + ); + }); +}); 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 fd0e23901bc9c..b17a5265c986e 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 @@ -142,36 +142,43 @@ export const Description = React.memo( isTextArea = false, disableAutoSave = false, marginRight, - }) => ( - - - {isTextArea ? ( - - updateDescription({ id: timelineId, description: e.target.value, disableAutoSave }) - } - placeholder={i18n.DESCRIPTION} - value={description} - /> - ) : ( - updateDescription({ id: timelineId, description: e.target.value })} - placeholder={i18n.DESCRIPTION} - spellCheck={true} - value={description} - /> - )} - - - ) + }) => { + const onDescriptionChanged = useCallback( + (e) => { + updateDescription({ id: timelineId, description: e.target.value, disableAutoSave }); + }, + [updateDescription, disableAutoSave, timelineId] + ); + return ( + + + {isTextArea ? ( + + ) : ( + + )} + + + ); + } ); Description.displayName = 'Description'; 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 c21592bed12e0..10b505da5c76f 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 @@ -63,6 +63,46 @@ describe('useCreateTimelineButton', () => { }); }); + test('getButton renders correct iconType - EuiButton', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => useCreateTimelineButton({ timelineId: mockId, timelineType }), + { wrapper: wrapperContainer } + ); + await waitForNextUpdate(); + + const button = result.current.getButton({ + outline: true, + title: 'mock title', + iconType: 'pencil', + }); + const wrapper = shallow(button); + expect(wrapper.find('[data-test-subj="timeline-new-with-border"]').prop('iconType')).toEqual( + 'pencil' + ); + }); + }); + + test('getButton renders correct filling - EuiButton', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => useCreateTimelineButton({ timelineId: mockId, timelineType }), + { wrapper: wrapperContainer } + ); + await waitForNextUpdate(); + + const button = result.current.getButton({ + outline: true, + title: 'mock title', + fill: false, + }); + const wrapper = shallow(button); + expect(wrapper.find('[data-test-subj="timeline-new-with-border"]').prop('fill')).toEqual( + false + ); + }); + }); + test('getButton renders correct outline - EuiButtonEmpty', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook( From d7686f6bead5a1b725ef8826e8bd138bbd81f6a1 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 27 Oct 2020 10:29:44 +0000 Subject: [PATCH 07/16] rename constant --- x-pack/plugins/security_solution/common/constants.ts | 2 +- .../components/timeline/properties/properties_left.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 50df6b8cf88ac..eee7961deaf09 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -180,4 +180,4 @@ export const showAllOthersBucket: string[] = [ 'user.name', ]; -export const enableNewTimeline = false; +export const ENABLE_NEWTIMELINE = false; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx index 7e2f9440ec4d3..155fd60c0200d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx @@ -17,7 +17,7 @@ import { TimelineTypeLiteral, TimelineStatusLiteral } from '../../../../../commo import * as i18n from './translations'; import { SaveTimelineButton } from '../header/save_timeline'; -import { enableNewTimeline } from '../../../../../common/constants'; +import { ENABLE_NEWTIMELINE } from '../../../../../common/constants'; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; @@ -130,7 +130,7 @@ export const PropertiesLeft = React.memo( ) : null} - {enableNewTimeline && ( + {ENABLE_NEWTIMELINE && ( Date: Tue, 27 Oct 2020 11:56:55 +0000 Subject: [PATCH 08/16] break components into files --- .../components/flyout/header/index.tsx | 4 - .../timeline/header/save_timeline.test.tsx | 232 +----------------- .../timeline/header/save_timeline.tsx | 200 +-------------- .../header/save_timeline_button.test.tsx | 35 +++ .../timeline/header/save_timeline_button.tsx | 22 ++ .../header/title_and_description.test.tsx | 210 ++++++++++++++++ .../timeline/header/title_and_description.tsx | 171 +++++++++++++ .../components/timeline/properties/index.tsx | 4 - .../timeline/properties/properties_left.tsx | 7 +- 9 files changed, 449 insertions(+), 436 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx 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 d7b5c3e0f4f6b..0737db7a00788 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 @@ -20,7 +20,6 @@ import { timelineActions, timelineSelectors } from '../../../store/timeline'; import { TimelineModel } from '../../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; import { InputsModelId } from '../../../../common/store/inputs/constants'; -import { TimelineInput } from '../../../store/timeline/actions'; interface OwnProps { timelineId: string; @@ -40,7 +39,6 @@ const StatefulFlyoutHeader = React.memo( noteIds, notesById, status, - saveTimeline, timelineId, timelineType, title, @@ -66,7 +64,6 @@ const StatefulFlyoutHeader = React.memo( isFavorite={isFavorite} noteIds={noteIds} status={status} - saveTimeline={saveTimeline} timelineId={timelineId} timelineType={timelineType} title={title} @@ -151,7 +148,6 @@ const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ }) => dispatch(timelineActions.updateTitle({ id, title, disableAutoSave })), toggleLock: ({ linkToId }: { linkToId: InputsModelId }) => dispatch(inputsActions.toggleTimelineLinkTo({ linkToId })), - saveTimeline: (args: TimelineInput) => dispatch(timelineActions.saveTimeline(args)), }); const connector = connect(makeMapStateToProps, mapDispatchToProps); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.test.tsx index 32916d875b496..85243f4622ccb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.test.tsx @@ -6,212 +6,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { - SaveTimelineButton, - SaveTimelineComponent, - TimelineTitleAndDescription, -} from './save_timeline'; -import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; -import { useCreateTimelineButton } from '../properties/use_create_timeline'; -import { TimelineType } from '../../../../../common/types/timeline'; -import * as i18n from './translations'; - -jest.mock('../../../../common/hooks/use_selector', () => ({ - useShallowEqualSelector: jest.fn(), -})); - -jest.mock('../../../../timelines/store/timeline', () => ({ - timelineSelectors: { - selectTimeline: jest.fn(), - }, -})); - -jest.mock('../properties/use_create_timeline', () => ({ - useCreateTimelineButton: jest.fn(), -})); - -describe('TimelineTitleAndDescription', () => { - describe('save timeline', () => { - const props = { - timelineId: 'timeline-1', - toggleSaveTimeline: jest.fn(), - onSaveTimeline: jest.fn(), - updateTitle: jest.fn(), - updateDescription: jest.fn(), - }; - - const mockGetButton = jest.fn().mockReturnValue(
); - - beforeEach(() => { - (useShallowEqualSelector as jest.Mock).mockReturnValue({ - description: '', - isSaving: true, - savedObjectId: null, - title: 'my timeline', - timelineType: TimelineType.default, - }); - (useCreateTimelineButton as jest.Mock).mockReturnValue({ - getButton: mockGetButton, - }); - }); - - afterEach(() => { - (useShallowEqualSelector as jest.Mock).mockReset(); - (useCreateTimelineButton as jest.Mock).mockReset(); - mockGetButton.mockClear(); - }); - - test('show proress bar while saving', () => { - const component = shallow(); - expect(component.find('[data-test-subj="progress-bar"]').exists()).toEqual(true); - }); - - test('Show correct header for save timeline modal', () => { - const component = shallow(); - expect(component.find('[data-test-subj="modal-header"]').prop('children')).toEqual( - i18n.SAVE_TIMELINE - ); - }); - - test('Show correct header for save timeline template modal', () => { - (useShallowEqualSelector as jest.Mock).mockReturnValue({ - description: '', - isSaving: true, - savedObjectId: null, - title: 'my timeline', - timelineType: TimelineType.template, - }); - const component = shallow(); - expect(component.find('[data-test-subj="modal-header"]').prop('children')).toEqual( - i18n.SAVE_TIMELINE_TEMPLATE - ); - }); - - test('Show name field', () => { - const component = shallow(); - expect(component.find('[data-test-subj="save-timeline-name"]').exists()).toEqual(true); - }); - - test('Show description field', () => { - const component = shallow(); - expect(component.find('[data-test-subj="save-timeline-description"]').exists()).toEqual(true); - }); - - test('Show discardTimelineButton', () => { - const component = shallow(); - expect(component.find('[data-test-subj="mock-discard-button"]').exists()).toEqual(true); - }); - - test('get discardTimelineButton with correct props', () => { - shallow(); - expect(mockGetButton).toBeCalledWith({ - title: i18n.DISCARD_TIMELINE, - outline: true, - iconType: '', - fill: false, - }); - }); - - test('get discardTimelineTemplateButton with correct props', () => { - (useShallowEqualSelector as jest.Mock).mockReturnValue({ - description: 'xxxx', - isSaving: true, - savedObjectId: null, - title: 'my timeline', - timelineType: TimelineType.template, - }); - shallow(); - expect(mockGetButton).toBeCalledWith({ - title: i18n.DISCARD_TIMELINE_TEMPLATE, - outline: true, - iconType: '', - fill: false, - }); - }); - - test('Show saveButton', () => { - const component = shallow(); - expect(component.find('[data-test-subj="save-button"]').exists()).toEqual(true); - }); - }); - - describe('update timeline', () => { - const props = { - timelineId: 'timeline-1', - toggleSaveTimeline: jest.fn(), - onSaveTimeline: jest.fn(), - updateTitle: jest.fn(), - updateDescription: jest.fn(), - }; - - const mockGetButton = jest.fn().mockReturnValue(
); - - beforeEach(() => { - (useShallowEqualSelector as jest.Mock).mockReturnValue({ - description: 'xxxx', - isSaving: true, - savedObjectId: '1234', - title: 'my timeline', - timelineType: TimelineType.default, - }); - (useCreateTimelineButton as jest.Mock).mockReturnValue({ - getButton: mockGetButton, - }); - }); - - afterEach(() => { - (useShallowEqualSelector as jest.Mock).mockReset(); - (useCreateTimelineButton as jest.Mock).mockReset(); - mockGetButton.mockClear(); - }); - - test('show proress bar while saving', () => { - const component = shallow(); - expect(component.find('[data-test-subj="progress-bar"]').exists()).toEqual(true); - }); - - test('Show correct header for save timeline modal', () => { - const component = shallow(); - expect(component.find('[data-test-subj="modal-header"]').prop('children')).toEqual( - i18n.NAME_TIMELINE - ); - }); - - test('Show correct header for save timeline template modal', () => { - (useShallowEqualSelector as jest.Mock).mockReturnValue({ - description: 'xxxx', - isSaving: true, - savedObjectId: '1234', - title: 'my timeline', - timelineType: TimelineType.template, - }); - const component = shallow(); - expect(component.find('[data-test-subj="modal-header"]').prop('children')).toEqual( - i18n.NAME_TIMELINE_TEMPLATE - ); - }); - - test('Show name field', () => { - const component = shallow(); - expect(component.find('[data-test-subj="save-timeline-name"]').exists()).toEqual(true); - }); - - test('Show description field', () => { - const component = shallow(); - expect(component.find('[data-test-subj="save-timeline-description"]').exists()).toEqual(true); - }); - - test('Show discardTimelineButton', () => { - const component = shallow(); - expect(component.find('[data-test-subj="mock-discard-button"]').exists()).toEqual(false); - }); - - test('Show saveButton', () => { - const component = shallow(); - expect(component.find('[data-test-subj="save-button"]').exists()).toEqual(true); - }); - }); -}); +import { SaveTimelineComponent } from './save_timeline'; describe('SaveTimelineComponent', () => { const props = { @@ -243,28 +38,3 @@ describe('SaveTimelineComponent', () => { expect(component.find('[data-test-subj="save-timeline-modal"]').exists()).toEqual(false); }); }); - -describe('SaveTimelineButton', () => { - const props = { - timelineId: 'timeline-1', - showOverlay: false, - toolTip: 'tooltip message', - toggleSaveTimeline: jest.fn(), - onSaveTimeline: jest.fn(), - updateTitle: jest.fn(), - updateDescription: jest.fn(), - }; - test('Show tooltip', () => { - const component = shallow(); - expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(true); - }); - - test('Hide tooltip', () => { - const testProps = { - ...props, - showOverlay: true, - }; - const component = shallow(); - expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(false); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx index 4281b5b528ad6..9d3ca6cd33c7b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx @@ -4,195 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiButton, - EuiButtonIcon, - EuiFlexGroup, - EuiFormRow, - EuiFlexItem, - EuiModal, - EuiModalBody, - EuiModalHeader, - EuiOverlayMask, - EuiSpacer, - EuiToolTip, - EuiProgress, -} from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; -import styled from 'styled-components'; -import { TimelineType } from '../../../../../common/types/timeline'; -import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; -import { timelineSelectors } from '../../../../timelines/store/timeline'; -import { - Description, - Name, - UpdateTitle, - UpdateDescription, - SaveTimeline, -} from '../properties/helpers'; +import { EuiButtonIcon, EuiModal, EuiOverlayMask } from '@elastic/eui'; +import React from 'react'; +import { UpdateTitle, UpdateDescription } from '../properties/helpers'; import { NOTES_PANEL_WIDTH } from '../properties/notes_size'; -import { TIMELINE_TITLE, DESCRIPTION } from '../properties/translations'; -import { useCreateTimelineButton } from '../properties/use_create_timeline'; -import * as i18n from './translations'; -interface SaveTimelineButtonProps { +import { TimelineTitleAndDescription } from './title_and_description'; + +export interface SaveTimelineComponentProps { timelineId: string; showOverlay: boolean; toolTip?: string; toggleSaveTimeline: () => void; - onSaveTimeline: SaveTimeline; - updateTitle: UpdateTitle; - updateDescription: UpdateDescription; -} - -interface TimelineTitleAndDescriptionProps { - timelineId: string; - toggleSaveTimeline: () => void; - onSaveTimeline: SaveTimeline; updateTitle: UpdateTitle; updateDescription: UpdateDescription; } -const Wrapper = styled(EuiModalBody)` - .euiFormRow { - max-width: none; - } - - .euiFormControlLayout { - max-width: none; - } - - .euiFieldText { - max-width: none; - } -`; - -Wrapper.displayName = 'Wrapper'; - -export const TimelineTitleAndDescription = React.memo( - ({ timelineId, toggleSaveTimeline, onSaveTimeline, updateTitle, updateDescription }) => { - const timeline = useShallowEqualSelector((state) => - timelineSelectors.selectTimeline(state, timelineId) - ); - - const { description, isSaving, savedObjectId, title, timelineType } = timeline; - - const handleClick = useCallback(() => { - onSaveTimeline({ - ...timeline, - id: timelineId, - }); - }, [onSaveTimeline, timeline, timelineId]); - - const { getButton } = useCreateTimelineButton({ timelineId, timelineType }); - - const discardTimelineButton = useMemo( - () => - getButton({ - title: - timelineType === TimelineType.template - ? i18n.DISCARD_TIMELINE_TEMPLATE - : i18n.DISCARD_TIMELINE, - outline: true, - iconType: '', - fill: false, - }), - [getButton, timelineType] - ); - - const modalHeader = - savedObjectId == null - ? timelineType === TimelineType.template - ? i18n.SAVE_TIMELINE_TEMPLATE - : i18n.SAVE_TIMELINE - : timelineType === TimelineType.template - ? i18n.NAME_TIMELINE_TEMPLATE - : i18n.NAME_TIMELINE; - - const saveButtonTitle = - savedObjectId == null - ? timelineType === TimelineType.template - ? i18n.SAVE_TIMELINE_TEMPLATE - : i18n.SAVE_TIMELINE - : i18n.SAVE; - - return ( - <> - {isSaving && ( - - )} - {modalHeader} - - - - - - - - - - - - - - - - - - {savedObjectId == null ? ( - discardTimelineButton - ) : ( - - {i18n.CLOSE_MODAL} - - )} - - - - {saveButtonTitle} - - - - - - - ); - } -); - -TimelineTitleAndDescription.displayName = 'TimelineTitleAndDescription'; - -export const SaveTimelineComponent = React.memo( - ({ - timelineId, - showOverlay, - toggleSaveTimeline, - onSaveTimeline, - updateTitle, - updateDescription, - }) => ( +export const SaveTimelineComponent = React.memo( + ({ timelineId, showOverlay, toggleSaveTimeline, updateTitle, updateDescription }) => ( <> ( @@ -221,15 +49,3 @@ export const SaveTimelineComponent = React.memo( ) ); SaveTimelineComponent.displayName = 'SaveTimelineComponent'; - -export const SaveTimelineButton = React.memo( - ({ toolTip, ...saveTimelineButtonProps }) => - saveTimelineButtonProps.showOverlay ? ( - - ) : ( - - - - ) -); -SaveTimelineButton.displayName = 'SaveTimelineButton'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx new file mode 100644 index 0000000000000..9269b82673ba3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx @@ -0,0 +1,35 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { SaveTimelineButton } from './save_timeline_button'; + +describe('SaveTimelineButton', () => { + const props = { + timelineId: 'timeline-1', + showOverlay: false, + toolTip: 'tooltip message', + toggleSaveTimeline: jest.fn(), + onSaveTimeline: jest.fn(), + updateTitle: jest.fn(), + updateDescription: jest.fn(), + }; + test('Show tooltip', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(true); + }); + + test('Hide tooltip', () => { + const testProps = { + ...props, + showOverlay: true, + }; + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(false); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx new file mode 100644 index 0000000000000..9ad6da6a334aa --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx @@ -0,0 +1,22 @@ +/* + * 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 { EuiToolTip } from '@elastic/eui'; +import React from 'react'; + +import { SaveTimelineComponent, SaveTimelineComponentProps } from './save_timeline'; + +export const SaveTimelineButton = React.memo( + ({ toolTip, ...saveTimelineButtonProps }) => + saveTimelineButtonProps.showOverlay ? ( + + ) : ( + + + + ) +); +SaveTimelineButton.displayName = 'SaveTimelineButton'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx new file mode 100644 index 0000000000000..45d1da8817766 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx @@ -0,0 +1,210 @@ +/* + * 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 { shallow } from 'enzyme'; +import { TimelineTitleAndDescription } from './title_and_description'; +import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { useCreateTimelineButton } from '../properties/use_create_timeline'; +import { TimelineType } from '../../../../../common/types/timeline'; +import * as i18n from './translations'; + +jest.mock('../../../../common/hooks/use_selector', () => ({ + useShallowEqualSelector: jest.fn(), +})); + +jest.mock('../../../../timelines/store/timeline', () => ({ + timelineSelectors: { + selectTimeline: jest.fn(), + }, +})); + +jest.mock('../properties/use_create_timeline', () => ({ + useCreateTimelineButton: jest.fn(), +})); + +describe('TimelineTitleAndDescription', () => { + describe('save timeline', () => { + const props = { + timelineId: 'timeline-1', + toggleSaveTimeline: jest.fn(), + onSaveTimeline: jest.fn(), + updateTitle: jest.fn(), + updateDescription: jest.fn(), + }; + + const mockGetButton = jest.fn().mockReturnValue(
); + + beforeEach(() => { + (useShallowEqualSelector as jest.Mock).mockReturnValue({ + description: '', + isSaving: true, + savedObjectId: null, + title: 'my timeline', + timelineType: TimelineType.default, + }); + (useCreateTimelineButton as jest.Mock).mockReturnValue({ + getButton: mockGetButton, + }); + }); + + afterEach(() => { + (useShallowEqualSelector as jest.Mock).mockReset(); + (useCreateTimelineButton as jest.Mock).mockReset(); + mockGetButton.mockClear(); + }); + + test('show proress bar while saving', () => { + const component = shallow(); + expect(component.find('[data-test-subj="progress-bar"]').exists()).toEqual(true); + }); + + test('Show correct header for save timeline modal', () => { + const component = shallow(); + expect(component.find('[data-test-subj="modal-header"]').prop('children')).toEqual( + i18n.SAVE_TIMELINE + ); + }); + + test('Show correct header for save timeline template modal', () => { + (useShallowEqualSelector as jest.Mock).mockReturnValue({ + description: '', + isSaving: true, + savedObjectId: null, + title: 'my timeline', + timelineType: TimelineType.template, + }); + const component = shallow(); + expect(component.find('[data-test-subj="modal-header"]').prop('children')).toEqual( + i18n.SAVE_TIMELINE_TEMPLATE + ); + }); + + test('Show name field', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-name"]').exists()).toEqual(true); + }); + + test('Show description field', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-description"]').exists()).toEqual(true); + }); + + test('Show discardTimelineButton', () => { + const component = shallow(); + expect(component.find('[data-test-subj="mock-discard-button"]').exists()).toEqual(true); + }); + + test('get discardTimelineButton with correct props', () => { + shallow(); + expect(mockGetButton).toBeCalledWith({ + title: i18n.DISCARD_TIMELINE, + outline: true, + iconType: '', + fill: false, + }); + }); + + test('get discardTimelineTemplateButton with correct props', () => { + (useShallowEqualSelector as jest.Mock).mockReturnValue({ + description: 'xxxx', + isSaving: true, + savedObjectId: null, + title: 'my timeline', + timelineType: TimelineType.template, + }); + shallow(); + expect(mockGetButton).toBeCalledWith({ + title: i18n.DISCARD_TIMELINE_TEMPLATE, + outline: true, + iconType: '', + fill: false, + }); + }); + + test('Show saveButton', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-button"]').exists()).toEqual(true); + }); + }); + + describe('update timeline', () => { + const props = { + timelineId: 'timeline-1', + toggleSaveTimeline: jest.fn(), + onSaveTimeline: jest.fn(), + updateTitle: jest.fn(), + updateDescription: jest.fn(), + }; + + const mockGetButton = jest.fn().mockReturnValue(
); + + beforeEach(() => { + (useShallowEqualSelector as jest.Mock).mockReturnValue({ + description: 'xxxx', + isSaving: true, + savedObjectId: '1234', + title: 'my timeline', + timelineType: TimelineType.default, + }); + (useCreateTimelineButton as jest.Mock).mockReturnValue({ + getButton: mockGetButton, + }); + }); + + afterEach(() => { + (useShallowEqualSelector as jest.Mock).mockReset(); + (useCreateTimelineButton as jest.Mock).mockReset(); + mockGetButton.mockClear(); + }); + + test('show proress bar while saving', () => { + const component = shallow(); + expect(component.find('[data-test-subj="progress-bar"]').exists()).toEqual(true); + }); + + test('Show correct header for save timeline modal', () => { + const component = shallow(); + expect(component.find('[data-test-subj="modal-header"]').prop('children')).toEqual( + i18n.NAME_TIMELINE + ); + }); + + test('Show correct header for save timeline template modal', () => { + (useShallowEqualSelector as jest.Mock).mockReturnValue({ + description: 'xxxx', + isSaving: true, + savedObjectId: '1234', + title: 'my timeline', + timelineType: TimelineType.template, + }); + const component = shallow(); + expect(component.find('[data-test-subj="modal-header"]').prop('children')).toEqual( + i18n.NAME_TIMELINE_TEMPLATE + ); + }); + + test('Show name field', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-name"]').exists()).toEqual(true); + }); + + test('Show description field', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-description"]').exists()).toEqual(true); + }); + + test('Show discardTimelineButton', () => { + const component = shallow(); + expect(component.find('[data-test-subj="mock-discard-button"]').exists()).toEqual(false); + }); + + test('Show saveButton', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-button"]').exists()).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx new file mode 100644 index 0000000000000..453ed043308e2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx @@ -0,0 +1,171 @@ +/* + * 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 { + EuiButton, + EuiFlexGroup, + EuiFormRow, + EuiFlexItem, + EuiModalBody, + EuiModalHeader, + EuiSpacer, + EuiProgress, +} from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; +import { TimelineType } from '../../../../../common/types/timeline'; +import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline'; +import { TimelineInput } from '../../../store/timeline/actions'; +import { Description, Name, UpdateTitle, UpdateDescription } from '../properties/helpers'; +import { TIMELINE_TITLE, DESCRIPTION } from '../properties/translations'; +import { useCreateTimelineButton } from '../properties/use_create_timeline'; +import * as i18n from './translations'; + +interface TimelineTitleAndDescriptionProps { + timelineId: string; + toggleSaveTimeline: () => void; + updateTitle: UpdateTitle; + updateDescription: UpdateDescription; +} + +const Wrapper = styled(EuiModalBody)` + .euiFormRow { + max-width: none; + } + + .euiFormControlLayout { + max-width: none; + } + + .euiFieldText { + max-width: none; + } +`; + +Wrapper.displayName = 'Wrapper'; + +export const TimelineTitleAndDescription = React.memo( + ({ timelineId, toggleSaveTimeline, updateTitle, updateDescription }) => { + const timeline = useShallowEqualSelector((state) => + timelineSelectors.selectTimeline(state, timelineId) + ); + + const { description, isSaving, savedObjectId, title, timelineType } = timeline; + + const dispatch = useDispatch(); + const onSaveTimeline = useCallback( + (args: TimelineInput) => dispatch(timelineActions.saveTimeline(args)), + [dispatch] + ); + + const handleClick = useCallback(() => { + onSaveTimeline({ + ...timeline, + id: timelineId, + }); + }, [onSaveTimeline, timeline, timelineId]); + + const { getButton } = useCreateTimelineButton({ timelineId, timelineType }); + + const discardTimelineButton = useMemo( + () => + getButton({ + title: + timelineType === TimelineType.template + ? i18n.DISCARD_TIMELINE_TEMPLATE + : i18n.DISCARD_TIMELINE, + outline: true, + iconType: '', + fill: false, + }), + [getButton, timelineType] + ); + + const modalHeader = + savedObjectId == null + ? timelineType === TimelineType.template + ? i18n.SAVE_TIMELINE_TEMPLATE + : i18n.SAVE_TIMELINE + : timelineType === TimelineType.template + ? i18n.NAME_TIMELINE_TEMPLATE + : i18n.NAME_TIMELINE; + + const saveButtonTitle = + savedObjectId == null + ? timelineType === TimelineType.template + ? i18n.SAVE_TIMELINE_TEMPLATE + : i18n.SAVE_TIMELINE + : i18n.SAVE; + + return ( + <> + {isSaving && ( + + )} + {modalHeader} + + + + + + + + + + + + + + + + + + {savedObjectId == null ? ( + discardTimelineButton + ) : ( + + {i18n.CLOSE_MODAL} + + )} + + + + {saveButtonTitle} + + + + + + + ); + } +); + +TimelineTitleAndDescription.displayName = 'TimelineTitleAndDescription'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx index bfd620fa971a1..e9aaa3719d363 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx @@ -17,7 +17,6 @@ import { TimelineProperties } from './styles'; import { PropertiesRight } from './properties_right'; import { PropertiesLeft } from './properties_left'; import { useAllCasesModal } from '../../../../cases/components/use_all_cases_modal'; -import { SaveTimeline } from './helpers'; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; @@ -36,7 +35,6 @@ interface Props { timelineId: string; timelineType: TimelineTypeLiteral; status: TimelineStatusLiteral; - saveTimeline: SaveTimeline; title: string; toggleLock: ToggleLock; updateDescription: UpdateDescription; @@ -68,7 +66,6 @@ export const Properties = React.memo( isDatepickerLocked, isFavorite, noteIds, - saveTimeline, status, timelineId, timelineType, @@ -129,7 +126,6 @@ export const Properties = React.memo( onToggleShowNotes={onToggleShowNotes} onToggleSaveTimeline={onToggleSaveTimeline} status={status} - saveTimeline={saveTimeline} showDescription={width >= showDescriptionThreshold} showNotes={showNotes} showNotesFromWidth={width >= showNotesThreshold} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx index 155fd60c0200d..4456014d6cf4b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx @@ -8,7 +8,7 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/e import React from 'react'; import styled from 'styled-components'; -import { Description, Name, NotesButton, SaveTimeline, StarIcon } from './helpers'; +import { Description, Name, NotesButton, StarIcon } from './helpers'; import { AssociateNote, UpdateNote } from '../../notes/helpers'; import { Note } from '../../../../common/lib/note'; @@ -16,7 +16,7 @@ import { SuperDatePicker } from '../../../../common/components/super_date_picker import { TimelineTypeLiteral, TimelineStatusLiteral } from '../../../../../common/types/timeline'; import * as i18n from './translations'; -import { SaveTimelineButton } from '../header/save_timeline'; +import { SaveTimelineButton } from '../header/save_timeline_button'; import { ENABLE_NEWTIMELINE } from '../../../../../common/constants'; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; @@ -35,7 +35,6 @@ interface Props { updateDescription: UpdateDescription; showNotes: boolean; status: TimelineStatusLiteral; - saveTimeline: SaveTimeline; associateNote: AssociateNote; showNotesFromWidth: boolean; showSaveTimelineOverlay: boolean; @@ -91,7 +90,6 @@ export const PropertiesLeft = React.memo( updateTitle, updateDescription, status, - saveTimeline, showNotes, showNotesFromWidth, showSaveTimelineOverlay, @@ -135,7 +133,6 @@ export const PropertiesLeft = React.memo( timelineId={timelineId} showOverlay={showSaveTimelineOverlay} toggleSaveTimeline={onToggleSaveTimeline} - onSaveTimeline={saveTimeline} updateTitle={updateTitle} updateDescription={updateDescription} /> From 899336b24698aecdc023e60188c9d093608e1872 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 27 Oct 2020 14:54:28 +0000 Subject: [PATCH 09/16] autoFocus and close modal on finish --- .../header/title_and_description.test.tsx | 8 ++ .../timeline/header/title_and_description.tsx | 29 +++- .../timeline/properties/helpers.tsx | 129 ++++++++++++------ 3 files changed, 117 insertions(+), 49 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx index 45d1da8817766..ea05e75317048 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx @@ -26,6 +26,14 @@ jest.mock('../properties/use_create_timeline', () => ({ useCreateTimelineButton: jest.fn(), })); +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useDispatch: jest.fn(), + }; +}); + describe('TimelineTitleAndDescription', () => { describe('save timeline', () => { const props = { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx index 453ed043308e2..8068e594f473a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx @@ -14,7 +14,7 @@ import { EuiSpacer, EuiProgress, } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import { TimelineType } from '../../../../../common/types/timeline'; @@ -49,6 +49,14 @@ const Wrapper = styled(EuiModalBody)` Wrapper.displayName = 'Wrapper'; +const usePrevious = (value: unknown) => { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; +}; + export const TimelineTitleAndDescription = React.memo( ({ timelineId, toggleSaveTimeline, updateTitle, updateDescription }) => { const timeline = useShallowEqualSelector((state) => @@ -57,6 +65,7 @@ export const TimelineTitleAndDescription = React.memo dispatch(timelineActions.saveTimeline(args)), @@ -86,6 +95,12 @@ export const TimelineTitleAndDescription = React.memo { + if (!isSaving && prevIsSaving) { + toggleSaveTimeline(); + } + }, [isSaving, prevIsSaving, toggleSaveTimeline]); + const modalHeader = savedObjectId == null ? timelineType === TimelineType.template @@ -113,14 +128,17 @@ export const TimelineTitleAndDescription = React.memo @@ -128,13 +146,14 @@ export const TimelineTitleAndDescription = React.memo 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 b17a5265c986e..d1aab90e64a08 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 @@ -18,7 +18,7 @@ import { EuiToolTip, EuiTextArea, } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import uuid from 'uuid'; import styled from 'styled-components'; import { useDispatch } from 'react-redux'; @@ -57,6 +57,7 @@ import { useCreateTimelineButton } from './use_create_timeline'; export const historyToolTip = 'The chronological history of actions related to this timeline'; export const streamLiveToolTip = 'Update the Timeline as new data arrives'; export const newTimelineToolTip = 'Create a new timeline'; +export const TIMELINE_TITLE_CLASSNAME = 'timeline-title'; const NotesCountBadge = (styled(EuiBadge)` margin-left: 5px; @@ -131,6 +132,7 @@ interface DescriptionProps { updateDescription: UpdateDescription; isTextArea?: boolean; disableAutoSave?: boolean; + disableTooltip?: boolean; marginRight?: number; } @@ -141,6 +143,7 @@ export const Description = React.memo( updateDescription, isTextArea = false, disableAutoSave = false, + disableTooltip = false, marginRight, }) => { const onDescriptionChanged = useCallback( @@ -149,33 +152,42 @@ export const Description = React.memo( }, [updateDescription, disableAutoSave, timelineId] ); + const inputField = useMemo( + () => + isTextArea ? ( + + ) : ( + + ), + [description, isTextArea, onDescriptionChanged] + ); return ( - - {isTextArea ? ( - - ) : ( - - )} - + {disableTooltip ? ( + inputField + ) : ( + + {inputField} + + )} ); } @@ -183,48 +195,77 @@ export const Description = React.memo( Description.displayName = 'Description'; interface NameProps { + autoFocus?: boolean; + className?: string; + disableAutoSave?: boolean; + disableTooltip?: boolean; timelineId: string; timelineType: TimelineType; title: string; updateTitle: UpdateTitle; width?: string; marginRight?: number; - disableAutoSave?: boolean; } export const Name = React.memo( ({ + autoFocus = false, + className = TIMELINE_TITLE_CLASSNAME, + disableAutoSave = false, + disableTooltip = false, timelineId, timelineType, title, updateTitle, width, marginRight, - disableAutoSave = false, }) => { const handleChange = useCallback( (e) => updateTitle({ id: timelineId, title: e.target.value, disableAutoSave }), [timelineId, updateTitle, disableAutoSave] ); + useEffect(() => { + const focusInput = () => { + const elements = document.querySelector(`.${className}`); + + if (elements != null) { + elements.focus(); + } + }; + if (autoFocus) { + focusInput(); + } + }, [autoFocus, className]); + + const nameField = useMemo( + () => ( + + ), + [handleChange, marginRight, timelineType, title, width, className] + ); + return ( - - - + {disableTooltip ? ( + nameField + ) : ( + + {nameField} + + )} ); } From aa33e3f51c2bf8003ce7aca63a7466a9bdc4ef6a Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 27 Oct 2020 15:47:11 +0000 Subject: [PATCH 10/16] rename constant --- x-pack/plugins/security_solution/common/constants.ts | 2 +- .../components/timeline/properties/properties_left.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index eee7961deaf09..767a2616a4c7e 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -180,4 +180,4 @@ export const showAllOthersBucket: string[] = [ 'user.name', ]; -export const ENABLE_NEWTIMELINE = false; +export const ENABLE_NEW_TIMELINE = false; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx index 4456014d6cf4b..04e1569a060db 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx @@ -17,7 +17,7 @@ import { TimelineTypeLiteral, TimelineStatusLiteral } from '../../../../../commo import * as i18n from './translations'; import { SaveTimelineButton } from '../header/save_timeline_button'; -import { ENABLE_NEWTIMELINE } from '../../../../../common/constants'; +import { ENABLE_NEW_TIMELINE } from '../../../../../common/constants'; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; @@ -128,7 +128,7 @@ export const PropertiesLeft = React.memo( ) : null} - {ENABLE_NEWTIMELINE && ( + {ENABLE_NEW_TIMELINE && ( Date: Wed, 28 Oct 2020 14:46:17 +0000 Subject: [PATCH 11/16] fix description label --- .../header/title_and_description.test.tsx | 103 +++++++++++++----- .../timeline/header/title_and_description.tsx | 42 +++++-- .../timeline/header/translations.ts | 7 ++ .../timeline/properties/helpers.tsx | 11 +- .../timeline/properties/translations.ts | 7 ++ .../properties/use_create_timeline.tsx | 2 + 6 files changed, 133 insertions(+), 39 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx index ea05e75317048..bcc90a25d5789 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx @@ -101,36 +101,9 @@ describe('TimelineTitleAndDescription', () => { expect(component.find('[data-test-subj="save-timeline-description"]').exists()).toEqual(true); }); - test('Show discardTimelineButton', () => { + test('Show close button', () => { const component = shallow(); - expect(component.find('[data-test-subj="mock-discard-button"]').exists()).toEqual(true); - }); - - test('get discardTimelineButton with correct props', () => { - shallow(); - expect(mockGetButton).toBeCalledWith({ - title: i18n.DISCARD_TIMELINE, - outline: true, - iconType: '', - fill: false, - }); - }); - - test('get discardTimelineTemplateButton with correct props', () => { - (useShallowEqualSelector as jest.Mock).mockReturnValue({ - description: 'xxxx', - isSaving: true, - savedObjectId: null, - title: 'my timeline', - timelineType: TimelineType.template, - }); - shallow(); - expect(mockGetButton).toBeCalledWith({ - title: i18n.DISCARD_TIMELINE_TEMPLATE, - outline: true, - iconType: '', - fill: false, - }); + expect(component.find('[data-test-subj="close-button"]').exists()).toEqual(true); }); test('Show saveButton', () => { @@ -205,9 +178,79 @@ describe('TimelineTitleAndDescription', () => { expect(component.find('[data-test-subj="save-timeline-description"]').exists()).toEqual(true); }); + test('Show saveButton', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-button"]').exists()).toEqual(true); + }); + }); + + describe('showWarning', () => { + const props = { + timelineId: 'timeline-1', + toggleSaveTimeline: jest.fn(), + onSaveTimeline: jest.fn(), + updateTitle: jest.fn(), + updateDescription: jest.fn(), + showWarning: true, + }; + + const mockGetButton = jest.fn().mockReturnValue(
); + + beforeEach(() => { + (useShallowEqualSelector as jest.Mock).mockReturnValue({ + description: '', + isSaving: true, + savedObjectId: null, + title: 'my timeline', + timelineType: TimelineType.default, + showWarnging: true, + }); + (useCreateTimelineButton as jest.Mock).mockReturnValue({ + getButton: mockGetButton, + }); + }); + + afterEach(() => { + (useShallowEqualSelector as jest.Mock).mockReset(); + (useCreateTimelineButton as jest.Mock).mockReset(); + mockGetButton.mockClear(); + }); + + test('Show EuiCallOut', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-callout"]').exists()).toEqual(true); + }); + test('Show discardTimelineButton', () => { const component = shallow(); - expect(component.find('[data-test-subj="mock-discard-button"]').exists()).toEqual(false); + expect(component.find('[data-test-subj="mock-discard-button"]').exists()).toEqual(true); + }); + + test('get discardTimelineButton with correct props', () => { + shallow(); + expect(mockGetButton).toBeCalledWith({ + title: i18n.DISCARD_TIMELINE, + outline: true, + iconType: '', + fill: false, + }); + }); + + test('get discardTimelineTemplateButton with correct props', () => { + (useShallowEqualSelector as jest.Mock).mockReturnValue({ + description: 'xxxx', + isSaving: true, + savedObjectId: null, + title: 'my timeline', + timelineType: TimelineType.template, + }); + shallow(); + expect(mockGetButton).toBeCalledWith({ + title: i18n.DISCARD_TIMELINE_TEMPLATE, + outline: true, + iconType: '', + fill: false, + }); }); test('Show saveButton', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx index 8068e594f473a..fba3dfdac6619 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx @@ -13,6 +13,7 @@ import { EuiModalHeader, EuiSpacer, EuiProgress, + EuiCallOut, } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; @@ -22,11 +23,12 @@ import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline'; import { TimelineInput } from '../../../store/timeline/actions'; import { Description, Name, UpdateTitle, UpdateDescription } from '../properties/helpers'; -import { TIMELINE_TITLE, DESCRIPTION } from '../properties/translations'; +import { TIMELINE_TITLE, DESCRIPTION, OPTIONAL } from '../properties/translations'; import { useCreateTimelineButton } from '../properties/use_create_timeline'; import * as i18n from './translations'; interface TimelineTitleAndDescriptionProps { + showWarning?: boolean; timelineId: string; toggleSaveTimeline: () => void; updateTitle: UpdateTitle; @@ -57,8 +59,11 @@ const usePrevious = (value: unknown) => { return ref.current; }; +// when showWarning equals to true, +// the modal is used as a reminder for users to save / discard +// the unsaved timeline / template export const TimelineTitleAndDescription = React.memo( - ({ timelineId, toggleSaveTimeline, updateTitle, updateDescription }) => { + ({ timelineId, toggleSaveTimeline, updateTitle, updateDescription, showWarning }) => { const timeline = useShallowEqualSelector((state) => timelineSelectors.selectTimeline(state, timelineId) ); @@ -111,12 +116,18 @@ export const TimelineTitleAndDescription = React.memo i18n.UNSAVED_TIMELINE_WARNING(timelineType), [ + timelineType, + ]); + + const descriptionLabel = savedObjectId == null ? `${DESCRIPTION} (${OPTIONAL})` : DESCRIPTION; + return ( <> {isSaving && ( @@ -125,6 +136,16 @@ export const TimelineTitleAndDescription = React.memo{modalHeader} + {showWarning && ( + + + + )} - + - {savedObjectId == null ? ( + {savedObjectId == null && showWarning ? ( discardTimelineButton ) : ( - + {i18n.CLOSE_MODAL} )} + i18n.translate('xpack.securitySolution.timeline.saveTimeline.modal.warning.title', { + values: { timeline: timelineType === TimelineType.template ? 'Timeline template' : 'Timeline' }, + defaultMessage: 'You have an unsaved {timeline}, do you wish to save it?', + }); 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 d1aab90e64a08..e88de000c5d8a 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 @@ -133,6 +133,7 @@ interface DescriptionProps { isTextArea?: boolean; disableAutoSave?: boolean; disableTooltip?: boolean; + disabled?: boolean; marginRight?: number; } @@ -144,6 +145,7 @@ export const Description = React.memo( isTextArea = false, disableAutoSave = false, disableTooltip = false, + disabled = false, marginRight, }) => { const onDescriptionChanged = useCallback( @@ -152,6 +154,7 @@ export const Description = React.memo( }, [updateDescription, disableAutoSave, timelineId] ); + const inputField = useMemo( () => isTextArea ? ( @@ -162,6 +165,7 @@ export const Description = React.memo( onChange={onDescriptionChanged} placeholder={i18n.DESCRIPTION} value={description} + disabled={disabled} /> ) : ( ( value={description} /> ), - [description, isTextArea, onDescriptionChanged] + [description, isTextArea, onDescriptionChanged, disabled] ); return ( @@ -199,6 +203,7 @@ interface NameProps { className?: string; disableAutoSave?: boolean; disableTooltip?: boolean; + disabled?: boolean; timelineId: string; timelineType: TimelineType; title: string; @@ -213,6 +218,7 @@ export const Name = React.memo( className = TIMELINE_TITLE_CLASSNAME, disableAutoSave = false, disableTooltip = false, + disabled = false, timelineId, timelineType, title, @@ -243,6 +249,7 @@ export const Name = React.memo( ( className={className} /> ), - [handleChange, marginRight, timelineType, title, width, className] + [handleChange, marginRight, timelineType, title, width, className, disabled] ); return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts index 3fc1fdec9450a..78d01b2d98ab3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts @@ -194,3 +194,10 @@ export const UNLOCK_SYNC_MAIN_DATE_PICKER_ARIA = i18n.translate( defaultMessage: 'Unlock date picker to global date picker', } ); + +export const OPTIONAL = i18n.translate( + 'xpack.securitySolution.timeline.properties.timelineDescriptionOptional', + { + defaultMessage: 'Optional', + } +); 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 2bfaaa072d3b7..b4d168cc980b6 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 @@ -98,11 +98,13 @@ export const useCreateTimelineButton = ({ title, iconType = 'plusInCircle', fill = true, + isDisabled = false, }: { outline?: boolean; title?: string; iconType?: string; fill?: boolean; + isDisabled?: boolean; }) => { const buttonProps = { iconType, From cbf08e70bee79168505d185af9b87c0194b621ce Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Wed, 28 Oct 2020 15:39:07 +0000 Subject: [PATCH 12/16] update wording --- .../components/timeline/header/title_and_description.tsx | 1 + .../public/timelines/components/timeline/header/translations.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx index fba3dfdac6619..bbdafc62ce86e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx @@ -144,6 +144,7 @@ export const TimelineTitleAndDescription = React.memo + )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts index 05f85e69b02b1..9c9709613c4cb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts @@ -78,6 +78,6 @@ export const CLOSE_MODAL = i18n.translate( export const UNSAVED_TIMELINE_WARNING = (timelineType: TimelineTypeLiteral) => i18n.translate('xpack.securitySolution.timeline.saveTimeline.modal.warning.title', { - values: { timeline: timelineType === TimelineType.template ? 'Timeline template' : 'Timeline' }, + values: { timeline: timelineType === TimelineType.template ? 'timeline template' : 'timeline' }, defaultMessage: 'You have an unsaved {timeline}, do you wish to save it?', }); From d453fa27c25caeca54572f126cf60c8d43e4bd32 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 29 Oct 2020 12:51:01 +0000 Subject: [PATCH 13/16] review --- .../timeline/header/save_timeline.test.tsx | 40 --------- .../timeline/header/save_timeline.tsx | 51 ------------ .../header/save_timeline_button.test.tsx | 47 ++++++++++- .../timeline/header/save_timeline_button.tsx | 81 +++++++++++++++++-- .../timeline/header/translations.ts | 4 + .../components/timeline/properties/index.tsx | 8 +- .../timeline/properties/properties_left.tsx | 14 +--- 7 files changed, 123 insertions(+), 122 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.test.tsx deleted file mode 100644 index 85243f4622ccb..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.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 { shallow } from 'enzyme'; -import { SaveTimelineComponent } from './save_timeline'; - -describe('SaveTimelineComponent', () => { - const props = { - timelineId: 'timeline-1', - showOverlay: false, - toggleSaveTimeline: jest.fn(), - onSaveTimeline: jest.fn(), - updateTitle: jest.fn(), - updateDescription: jest.fn(), - }; - test('should show a button with pencil icon', () => { - const component = shallow(); - expect(component.find('[data-test-subj="save-timeline-button-icon"]').prop('iconType')).toEqual( - 'pencil' - ); - }); - - test('should show a modal when showOverlay equals true', () => { - const testProps = { - ...props, - showOverlay: true, - }; - const component = shallow(); - expect(component.find('[data-test-subj="save-timeline-modal"]').exists()).toEqual(true); - }); - - test('should not show a modal when showOverlay equals false', () => { - const component = shallow(); - expect(component.find('[data-test-subj="save-timeline-modal"]').exists()).toEqual(false); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.tsx deleted file mode 100644 index 9d3ca6cd33c7b..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline.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 { EuiButtonIcon, EuiModal, EuiOverlayMask } from '@elastic/eui'; -import React from 'react'; -import { UpdateTitle, UpdateDescription } from '../properties/helpers'; -import { NOTES_PANEL_WIDTH } from '../properties/notes_size'; - -import { TimelineTitleAndDescription } from './title_and_description'; - -export interface SaveTimelineComponentProps { - timelineId: string; - showOverlay: boolean; - toolTip?: string; - toggleSaveTimeline: () => void; - updateTitle: UpdateTitle; - updateDescription: UpdateDescription; -} - -export const SaveTimelineComponent = React.memo( - ({ timelineId, showOverlay, toggleSaveTimeline, updateTitle, updateDescription }) => ( - <> - - - {showOverlay ? ( - - - - - - ) : null} - - ) -); -SaveTimelineComponent.displayName = 'SaveTimelineComponent'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx index 9269b82673ba3..e0f92605afa25 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx @@ -5,9 +5,20 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, mount } from 'enzyme'; import { SaveTimelineButton } from './save_timeline_button'; +import { renderHook, act } from '@testing-library/react-hooks'; + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useDispatch: jest.fn(), + }; +}); + +jest.mock('./title_and_description'); describe('SaveTimelineButton', () => { const props = { @@ -29,7 +40,37 @@ describe('SaveTimelineButton', () => { ...props, showOverlay: true, }; - const component = shallow(); - expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(false); + const component = mount(); + component.find('[data-test-subj="save-timeline-button-icon"]').first().simulate('click'); + + act(() => { + expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual( + false + ); + }); + }); + + test('should show a button with pencil icon', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-button-icon"]').prop('iconType')).toEqual( + 'pencil' + ); + }); + + test('should not show a modal when showOverlay equals false', () => { + const component = shallow(); + expect(component.find('[data-test-subj="save-timeline-modal"]').exists()).toEqual(false); + }); + + test('should show a modal when showOverlay equals true', () => { + const testProps = { + ...props, + showOverlay: true, + }; + const component = mount(); + component.find('[data-test-subj="save-timeline-button-icon"]').first().simulate('click'); + act(() => { + expect(component.find('[data-test-subj="save-timeline-modal"]').exists()).toEqual(true); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx index 9ad6da6a334aa..32563a71b72c0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx @@ -4,19 +4,84 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiToolTip } from '@elastic/eui'; -import React from 'react'; +import { EuiButtonIcon, EuiOverlayMask, EuiModal, EuiToolTip } from '@elastic/eui'; -import { SaveTimelineComponent, SaveTimelineComponentProps } from './save_timeline'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { timelineActions } from '../../../store/timeline'; +import { NOTES_PANEL_WIDTH } from '../properties/notes_size'; + +import { TimelineTitleAndDescription } from './title_and_description'; +import { EDIT } from './translations'; + +export interface SaveTimelineComponentProps { + timelineId: string; + toolTip?: string; +} export const SaveTimelineButton = React.memo( - ({ toolTip, ...saveTimelineButtonProps }) => - saveTimelineButtonProps.showOverlay ? ( - + ({ timelineId, toolTip }) => { + const [showSaveTimelineOverlay, setShowSaveTimelineOverlay] = useState(false); + const onToggleSaveTimeline = useCallback(() => { + setShowSaveTimelineOverlay((prevShowSaveTimelineOverlay) => !prevShowSaveTimelineOverlay); + }, [setShowSaveTimelineOverlay]); + + const dispatch = useDispatch(); + const updateTitle = useCallback( + ({ id, title, disableAutoSave }: { id: string; title: string; disableAutoSave?: boolean }) => + dispatch(timelineActions.updateTitle({ id, title, disableAutoSave })), + [dispatch] + ); + + const updateDescription = useCallback( + ({ + id, + description, + disableAutoSave, + }: { + id: string; + description: string; + disableAutoSave?: boolean; + }) => dispatch(timelineActions.updateDescription({ id, description, disableAutoSave })), + [dispatch] + ); + + const saveTimelineButtonIcon = useMemo( + () => ( + + ), + [onToggleSaveTimeline] + ); + + return showSaveTimelineOverlay ? ( + <> + {saveTimelineButtonIcon} + + + + + + ) : ( - + {saveTimelineButtonIcon} - ) + ); + } ); + SaveTimelineButton.displayName = 'SaveTimelineButton'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts index 9c9709613c4cb..048a31ec7e919 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts @@ -23,6 +23,10 @@ export const CALL_OUT_IMMUTABLE = i18n.translate( } ); +export const EDIT = i18n.translate('xpack.securitySolution.timeline.saveTimeline.modal.button', { + defaultMessage: 'edit', +}); + export const SAVE_TIMELINE = i18n.translate( 'xpack.securitySolution.timeline.saveTimeline.modal.header', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx index e9aaa3719d363..9df2b585449a0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx @@ -81,7 +81,6 @@ export const Properties = React.memo( const [showActions, setShowActions] = useState(false); const [showNotes, setShowNotes] = useState(false); const [showTimelineModal, setShowTimelineModal] = useState(false); - const [showSaveTimelineOverlay, setShowSaveTimelineOverlay] = useState(false); const onButtonClick = useCallback(() => setShowActions(!showActions), [showActions]); const onToggleShowNotes = useCallback(() => setShowNotes(!showNotes), [showNotes]); @@ -93,10 +92,7 @@ export const Properties = React.memo( setShowTimelineModal(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const onToggleSaveTimeline = useCallback( - () => setShowSaveTimelineOverlay(!showSaveTimelineOverlay), - [setShowSaveTimelineOverlay, showSaveTimelineOverlay] - ); + const { Modal: AllCasesModal, onOpenModal: onOpenCaseModal } = useAllCasesModal({ timelineId }); const datePickerWidth = useMemo( @@ -124,12 +120,10 @@ export const Properties = React.memo( isFavorite={isFavorite} noteIds={noteIds} onToggleShowNotes={onToggleShowNotes} - onToggleSaveTimeline={onToggleSaveTimeline} status={status} showDescription={width >= showDescriptionThreshold} showNotes={showNotes} showNotesFromWidth={width >= showNotesThreshold} - showSaveTimelineOverlay={showSaveTimelineOverlay} timelineId={timelineId} timelineType={timelineType} title={title} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx index 04e1569a060db..6b181a5af7bf3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx @@ -37,7 +37,6 @@ interface Props { status: TimelineStatusLiteral; associateNote: AssociateNote; showNotesFromWidth: boolean; - showSaveTimelineOverlay: boolean; getNotesByIds: (noteIds: string[]) => Note[]; onToggleShowNotes: () => void; noteIds: string[]; @@ -45,7 +44,6 @@ interface Props { isDatepickerLocked: boolean; toggleLock: () => void; datePickerWidth: number; - onToggleSaveTimeline: () => void; } export const PropertiesLeftStyle = styled(EuiFlexGroup)` @@ -84,7 +82,6 @@ export const PropertiesLeft = React.memo( updateIsFavorite, showDescription, description, - onToggleSaveTimeline, title, timelineType, updateTitle, @@ -92,7 +89,6 @@ export const PropertiesLeft = React.memo( status, showNotes, showNotesFromWidth, - showSaveTimelineOverlay, associateNote, getNotesByIds, noteIds, @@ -128,15 +124,7 @@ export const PropertiesLeft = React.memo( ) : null} - {ENABLE_NEW_TIMELINE && ( - - )} + {ENABLE_NEW_TIMELINE && } {showNotesFromWidth ? ( From 43104b5efd3bd7e79548e3a5f9d1ccfb7ae1f314 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 29 Oct 2020 14:31:39 +0000 Subject: [PATCH 14/16] fix dependency --- .../components/timeline/header/save_timeline_button.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx index e0f92605afa25..e9dc312ee8d19 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; import { SaveTimelineButton } from './save_timeline_button'; -import { renderHook, act } from '@testing-library/react-hooks'; +import { act } from '@testing-library/react-hooks'; jest.mock('react-redux', () => { const actual = jest.requireActual('react-redux'); From 44a6cacce83bf23fa8575a730975720cc213cb4d Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 29 Oct 2020 15:45:37 +0000 Subject: [PATCH 15/16] remove classname --- .../timeline/header/title_and_description.tsx | 1 - .../timeline/properties/helpers.tsx | 23 +++++++------------ 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx index bbdafc62ce86e..3597b26e2663a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx @@ -151,7 +151,6 @@ export const TimelineTitleAndDescription = React.memo ( ({ autoFocus = false, - className = TIMELINE_TITLE_CLASSNAME, disableAutoSave = false, disableTooltip = false, disabled = false, @@ -226,23 +224,18 @@ export const Name = React.memo( width, marginRight, }) => { + const timelineNameRef = useRef(null); + const handleChange = useCallback( (e) => updateTitle({ id: timelineId, title: e.target.value, disableAutoSave }), [timelineId, updateTitle, disableAutoSave] ); useEffect(() => { - const focusInput = () => { - const elements = document.querySelector(`.${className}`); - - if (elements != null) { - elements.focus(); - } - }; - if (autoFocus) { - focusInput(); + if (autoFocus && timelineNameRef && timelineNameRef.current) { + timelineNameRef.current.focus(); } - }, [autoFocus, className]); + }, [autoFocus]); const nameField = useMemo( () => ( @@ -258,10 +251,10 @@ export const Name = React.memo( value={title} width={width} marginRight={marginRight} - className={className} + inputRef={timelineNameRef} /> ), - [handleChange, marginRight, timelineType, title, width, className, disabled] + [handleChange, marginRight, timelineType, title, width, disabled] ); return ( From d770bcff6535e878fa4012578d9332aa5be74868 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Fri, 30 Oct 2020 11:55:12 +0000 Subject: [PATCH 16/16] update wording --- .../components/timeline/header/save_timeline_button.tsx | 2 +- .../public/timelines/components/timeline/header/translations.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx index 32563a71b72c0..476ef8d1dd5a1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx @@ -77,7 +77,7 @@ export const SaveTimelineButton = React.memo( ) : ( - + {saveTimelineButtonIcon} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts index 048a31ec7e919..80aa719a3469d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts @@ -83,5 +83,5 @@ export const CLOSE_MODAL = i18n.translate( export const UNSAVED_TIMELINE_WARNING = (timelineType: TimelineTypeLiteral) => i18n.translate('xpack.securitySolution.timeline.saveTimeline.modal.warning.title', { values: { timeline: timelineType === TimelineType.template ? 'timeline template' : 'timeline' }, - defaultMessage: 'You have an unsaved {timeline}, do you wish to save it?', + defaultMessage: 'You have an unsaved {timeline}. Do you wish to save it?', });