diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx new file mode 100644 index 0000000000000..3019a8add4e36 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_super_select/index.tsx @@ -0,0 +1,137 @@ +/* + * 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 { EuiInputPopover, EuiSelectableOption, EuiFieldText } from '@elastic/eui'; +import React, { memo, useCallback, useMemo, useState } from 'react'; +import styled from 'styled-components'; + +import { OpenTimelineResult } from '../../open_timeline/types'; +import { SelectableTimeline } from '../selectable_timeline'; +import * as i18n from '../translations'; +import { TimelineType, TimelineTypeLiteral } from '../../../../../common/types/timeline'; + +const StyledEuiFieldText = styled(EuiFieldText)` + padding-left: 12px; + padding-right: 40px; + + &[readonly] { + cursor: pointer; + background-size: 0 100%; + background-repeat: no-repeat; + + // To match EuiFieldText focus state + &:focus { + background-color: #fff; + background-image: linear-gradient( + to top, + #006bb4, + #006bb4 2px, + transparent 2px, + transparent 100% + ); + background-size: 100% 100%; + } + } + + & + .euiFormControlLayoutIcons { + left: unset; + right: 12px; + } +`; + +interface SearchTimelineSuperSelectProps { + isDisabled: boolean; + hideUntitled?: boolean; + timelineId: string | null; + timelineTitle: string | null; + timelineType?: TimelineTypeLiteral; + onTimelineChange: (timelineTitle: string, timelineId: string | null) => void; +} + +const getBasicSelectableOptions = (timelineId: string) => [ + { + description: i18n.DEFAULT_TIMELINE_DESCRIPTION, + favorite: [], + label: i18n.DEFAULT_TIMELINE_TITLE, + id: undefined, + title: i18n.DEFAULT_TIMELINE_TITLE, + checked: timelineId === '-1' ? 'on' : undefined, + } as EuiSelectableOption, +]; + +const SearchTimelineSuperSelectComponent: React.FC = ({ + isDisabled, + hideUntitled = false, + timelineId, + timelineTitle, + timelineType = TimelineType.template, + onTimelineChange, +}) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const handleClosePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const handleOpenPopover = useCallback(() => { + setIsPopoverOpen(true); + }, []); + + const superSelect = useMemo( + () => ( + + ), + [handleOpenPopover, isDisabled, timelineTitle] + ); + + const handleGetSelectableOptions = useCallback( + ({ timelines, onlyFavorites, searchTimelineValue }) => [ + ...(!onlyFavorites && searchTimelineValue === '' + ? getBasicSelectableOptions(timelineId == null ? '-1' : timelineId) + : []), + ...timelines + .filter((t: OpenTimelineResult) => !hideUntitled || t.title !== '') + .map( + (t: OpenTimelineResult, index: number) => + ({ + description: t.description, + favorite: t.favorite, + label: t.title, + id: t.savedObjectId, + key: `${t.title}-${index}`, + title: t.title, + checked: t.savedObjectId === timelineId ? 'on' : undefined, + } as EuiSelectableOption) + ), + ], + [hideUntitled, timelineId] + ); + + return ( + + + + ); +}; + +export const SearchTimelineSuperSelect = memo(SearchTimelineSuperSelectComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx new file mode 100644 index 0000000000000..7ecbc9a53cb21 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx @@ -0,0 +1,311 @@ +/* + * 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 { + EuiSelectable, + EuiHighlight, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiTextColor, + EuiSelectableOption, + EuiPortal, + EuiFilterGroup, + EuiFilterButton, +} from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import React, { memo, useCallback, useMemo, useState, useEffect } from 'react'; +import { ListProps } from 'react-virtualized'; +import styled from 'styled-components'; + +import { + TimelineTypeLiteralWithNull, + TimelineTypeLiteral, +} from '../../../../../common/types/timeline'; + +import { useGetAllTimeline } from '../../../containers/all'; +import { SortFieldTimeline, Direction } from '../../../../graphql/types'; +import { isUntitled } from '../../open_timeline/helpers'; +import * as i18nTimeline from '../../open_timeline/translations'; +import { OpenTimelineResult } from '../../open_timeline/types'; +import { getEmptyTagValue } from '../../../../common/components/empty_value'; +import * as i18n from '../translations'; + +const MyEuiFlexItem = styled(EuiFlexItem)` + display: inline-block; + max-width: 296px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const MyEuiFlexGroup = styled(EuiFlexGroup)` + padding 0px 4px; +`; + +const EuiSelectableContainer = styled.div<{ isLoading: boolean }>` + .euiSelectable { + .euiFormControlLayout__childrenWrapper { + display: flex; + } + ${({ isLoading }) => `${ + isLoading + ? ` + .euiFormControlLayoutIcons { + display: none; + } + .euiFormControlLayoutIcons.euiFormControlLayoutIcons--right { + display: block; + left: 12px; + top: 12px; + }` + : '' + } + `} + } +`; + +export const ORIGINAL_PAGE_SIZE = 50; +const POPOVER_HEIGHT = 260; +const TIMELINE_ITEM_HEIGHT = 50; + +export interface GetSelectableOptions { + timelines: OpenTimelineResult[]; + onlyFavorites: boolean; + timelineType?: TimelineTypeLiteralWithNull; + searchTimelineValue: string; +} + +export interface SelectableTimelineProps { + hideUntitled?: boolean; + getSelectableOptions: ({ + timelines, + onlyFavorites, + timelineType, + searchTimelineValue, + }: GetSelectableOptions) => EuiSelectableOption[]; + onClosePopover: () => void; + onTimelineChange: ( + timelineTitle: string, + timelineId: string | null, + graphEventId?: string + ) => void; + timelineType: TimelineTypeLiteral; +} + +export interface SearchProps { + 'data-test-subj'?: string; + isLoading: boolean; + placeholder: string; + onSearch: (arg: string) => void; + incremental: boolean; + inputRef: (arg: HTMLElement) => void; +} + +const SelectableTimelineComponent: React.FC = ({ + hideUntitled = false, + getSelectableOptions, + onClosePopover, + onTimelineChange, + timelineType, +}) => { + const [pageSize, setPageSize] = useState(ORIGINAL_PAGE_SIZE); + const [heightTrigger, setHeightTrigger] = useState(0); + const [searchTimelineValue, setSearchTimelineValue] = useState(''); + const [onlyFavorites, setOnlyFavorites] = useState(false); + const [searchRef, setSearchRef] = useState(null); + const { fetchAllTimeline, timelines, loading, totalCount: timelineCount } = useGetAllTimeline(); + + const onSearchTimeline = useCallback((val) => { + setSearchTimelineValue(val); + }, []); + + const handleOnToggleOnlyFavorites = useCallback(() => { + setOnlyFavorites(!onlyFavorites); + }, [onlyFavorites]); + + const handleOnScroll = useCallback( + ( + totalTimelines: number, + totalCount: number, + { + clientHeight, + scrollHeight, + scrollTop, + }: { + clientHeight: number; + scrollHeight: number; + scrollTop: number; + } + ) => { + if (totalTimelines < totalCount) { + const clientHeightTrigger = clientHeight * 1.2; + if ( + scrollTop > 10 && + scrollHeight - scrollTop < clientHeightTrigger && + scrollHeight > heightTrigger + ) { + setHeightTrigger(scrollHeight); + setPageSize(pageSize + ORIGINAL_PAGE_SIZE); + } + } + }, + [heightTrigger, pageSize] + ); + + const renderTimelineOption = useCallback((option, searchValue) => { + return ( + + + + + + + + + {isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title} + + + + + + {option.description != null && option.description.trim().length > 0 + ? option.description + : getEmptyTagValue()} + + + + + + + + + + ); + }, []); + + const handleTimelineChange = useCallback( + (options) => { + const selectedTimeline = options.filter( + (option: { checked: string }) => option.checked === 'on' + ); + if (selectedTimeline != null && selectedTimeline.length > 0) { + onTimelineChange( + isEmpty(selectedTimeline[0].title) + ? i18nTimeline.UNTITLED_TIMELINE + : selectedTimeline[0].title, + selectedTimeline[0].id === '-1' ? null : selectedTimeline[0].id, + selectedTimeline[0].graphEventId ?? '' + ); + } + onClosePopover(); + }, + [onClosePopover, onTimelineChange] + ); + + const favoritePortal = useMemo( + () => + searchRef != null ? ( + + + + + + {i18nTimeline.ONLY_FAVORITES} + + + + + + ) : null, + [searchRef, onlyFavorites, handleOnToggleOnlyFavorites] + ); + + const searchProps: SearchProps = { + 'data-test-subj': 'timeline-super-select-search-box', + isLoading: loading, + placeholder: useMemo(() => i18n.SEARCH_BOX_TIMELINE_PLACEHOLDER(timelineType), [timelineType]), + onSearch: onSearchTimeline, + incremental: false, + inputRef: (ref: HTMLElement) => { + setSearchRef(ref); + }, + }; + + useEffect(() => { + fetchAllTimeline({ + pageInfo: { + pageIndex: 1, + pageSize, + }, + search: searchTimelineValue, + sort: { + sortField: SortFieldTimeline.updated, + sortOrder: Direction.desc, + }, + onlyUserFavorite: onlyFavorites, + status: null, + timelineType, + templateTimelineType: null, + }); + }, [fetchAllTimeline, onlyFavorites, pageSize, searchTimelineValue, timelineType]); + + return ( + + !hideUntitled || t.title !== '').length, + timelineCount + ), + } as unknown) as ListProps, + }} + renderOption={renderTimelineOption} + onChange={handleTimelineChange} + searchable + searchProps={searchProps} + singleSelection={true} + options={getSelectableOptions({ + timelines, + onlyFavorites, + searchTimelineValue, + timelineType, + })} + > + {(list, search) => ( + <> + {search} + {favoritePortal} + {list} + + )} + + + ); +}; + +export const SelectableTimeline = memo(SelectableTimelineComponent);