[SIEM] [Case] Insert timeline into case textarea (#59586)

This commit is contained in:
Steph Milovic 2020-03-10 12:49:55 -05:00 committed by GitHub
parent 8bc051ac49
commit ecac63f258
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 692 additions and 370 deletions

View file

@ -8,23 +8,27 @@ import { EuiFormRow } from '@elastic/eui';
import React, { useCallback } from 'react';
import { FieldHook, getFieldValidityAndErrorMessage } from '../../shared_imports';
import { MarkdownEditor } from '.';
import { CursorPosition, MarkdownEditor } from '.';
interface IMarkdownEditorForm {
bottomRightContent?: React.ReactNode;
dataTestSubj: string;
field: FieldHook;
idAria: string;
isDisabled: boolean;
onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void;
placeholder?: string;
footerContentRight?: React.ReactNode;
topRightContent?: React.ReactNode;
}
export const MarkdownEditorForm = ({
bottomRightContent,
dataTestSubj,
field,
idAria,
isDisabled = false,
onCursorPositionUpdate,
placeholder,
footerContentRight,
topRightContent,
}: IMarkdownEditorForm) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
@ -37,21 +41,23 @@ export const MarkdownEditorForm = ({
return (
<EuiFormRow
label={field.label}
labelAppend={field.labelAppend}
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
data-test-subj={dataTestSubj}
describedByIds={idAria ? [idAria] : undefined}
error={errorMessage}
fullWidth
helpText={field.helpText}
isInvalid={isInvalid}
label={field.label}
labelAppend={field.labelAppend}
>
<MarkdownEditor
initialContent={field.value as string}
bottomRightContent={bottomRightContent}
content={field.value as string}
isDisabled={isDisabled}
footerContentRight={footerContentRight}
onChange={handleContentChange}
onCursorPositionUpdate={onCursorPositionUpdate}
placeholder={placeholder}
topRightContent={topRightContent}
/>
</EuiFormRow>
);

View file

@ -12,7 +12,7 @@ import {
EuiTabbedContent,
EuiTextArea,
} from '@elastic/eui';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useMemo, useCallback, ChangeEvent } from 'react';
import styled, { css } from 'styled-components';
import { Markdown } from '../markdown';
@ -28,9 +28,25 @@ const Container = styled(EuiPanel)`
padding: 0;
background: ${theme.eui.euiColorLightestShade};
position: relative;
.markdown-tabs-header {
position: absolute;
top: ${theme.eui.euiSizeS};
right: ${theme.eui.euiSizeS};
z-index: ${theme.eui.euiZContentMenu};
}
.euiTab {
padding: 10px;
}
.markdown-tabs {
width: 100%;
}
.markdown-tabs-footer {
height: 41px;
padding: 0 ${theme.eui.euiSizeM};
.euiLink {
font-size: ${theme.eui.euiSizeM};
}
}
.euiFormRow__labelWrapper {
position: absolute;
top: -${theme.eui.euiSizeL};
@ -41,81 +57,108 @@ const Container = styled(EuiPanel)`
`}
`;
const Tabs = styled(EuiTabbedContent)`
width: 100%;
`;
const Footer = styled(EuiFlexGroup)`
${({ theme }) => css`
height: 41px;
padding: 0 ${theme.eui.euiSizeM};
.euiLink {
font-size: ${theme.eui.euiSizeM};
}
`}
`;
const MarkdownContainer = styled(EuiPanel)`
min-height: 150px;
overflow: auto;
`;
export interface CursorPosition {
start: number;
end: number;
}
/** An input for entering a new case description */
export const MarkdownEditor = React.memo<{
placeholder?: string;
footerContentRight?: React.ReactNode;
initialContent: string;
bottomRightContent?: React.ReactNode;
topRightContent?: React.ReactNode;
content: string;
isDisabled?: boolean;
onChange: (description: string) => void;
}>(({ placeholder, footerContentRight, initialContent, isDisabled = false, onChange }) => {
const [content, setContent] = useState(initialContent);
useEffect(() => {
onChange(content);
}, [content]);
const tabs = useMemo(
() => [
{
id: 'comment',
name: i18n.MARKDOWN,
content: (
<TextArea
onChange={e => {
setContent(e.target.value);
}}
aria-label={`markdown-editor-comment`}
fullWidth={true}
disabled={isDisabled}
placeholder={placeholder ?? ''}
spellCheck={false}
value={content}
/>
),
onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void;
placeholder?: string;
}>(
({
bottomRightContent,
topRightContent,
content,
isDisabled = false,
onChange,
placeholder,
onCursorPositionUpdate,
}) => {
const handleOnChange = useCallback(
(evt: ChangeEvent<HTMLTextAreaElement>) => {
onChange(evt.target.value);
},
{
id: 'preview',
name: i18n.PREVIEW,
content: (
<MarkdownContainer data-test-subj="markdown-container" paddingSize="s">
<Markdown raw={content} />
</MarkdownContainer>
),
},
],
[content, isDisabled, placeholder]
);
return (
<Container>
<Tabs data-test-subj={`markdown-tabs`} size="s" tabs={tabs} initialSelectedTab={tabs[0]} />
<Footer alignItems="center" gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiLink href={MARKDOWN_HELP_LINK} external target="_blank">
{i18n.MARKDOWN_SYNTAX_HELP}
</EuiLink>
</EuiFlexItem>
{footerContentRight && <EuiFlexItem grow={false}>{footerContentRight}</EuiFlexItem>}
</Footer>
</Container>
);
});
[onChange]
);
const setCursorPosition = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (onCursorPositionUpdate) {
onCursorPositionUpdate({
start: e!.target!.selectionStart ?? 0,
end: e!.target!.selectionEnd ?? 0,
});
}
return false;
};
const tabs = useMemo(
() => [
{
id: 'comment',
name: i18n.MARKDOWN,
content: (
<TextArea
onChange={handleOnChange}
onBlur={setCursorPosition}
aria-label={`markdown-editor-comment`}
fullWidth={true}
disabled={isDisabled}
placeholder={placeholder ?? ''}
spellCheck={false}
value={content}
/>
),
},
{
id: 'preview',
name: i18n.PREVIEW,
content: (
<MarkdownContainer data-test-subj="markdown-container" paddingSize="s">
<Markdown raw={content} />
</MarkdownContainer>
),
},
],
[content, isDisabled, placeholder]
);
return (
<Container>
{topRightContent && <div className={`markdown-tabs-header`}>{topRightContent}</div>}
<EuiTabbedContent
className={`markdown-tabs`}
data-test-subj={`markdown-tabs`}
size="s"
tabs={tabs}
initialSelectedTab={tabs[0]}
/>
<EuiFlexGroup
className={`markdown-tabs-footer`}
alignItems="center"
gutterSize="none"
justifyContent="spaceBetween"
>
<EuiFlexItem grow={false}>
<EuiLink href={MARKDOWN_HELP_LINK} external target="_blank">
{i18n.MARKDOWN_SYNTAX_HELP}
</EuiLink>
</EuiFlexItem>
{bottomRightContent && <EuiFlexItem grow={false}>{bottomRightContent}</EuiFlexItem>}
</EuiFlexGroup>
</Container>
);
}
);
MarkdownEditor.displayName = 'MarkdownEditor';

View file

@ -0,0 +1,85 @@
/*
* 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, EuiPopover, EuiSelectableOption } from '@elastic/eui';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { OpenTimelineResult } from '../../open_timeline/types';
import { SelectableTimeline } from '../selectable_timeline';
import * as i18n from '../translations';
interface InsertTimelinePopoverProps {
isDisabled: boolean;
hideUntitled?: boolean;
onTimelineChange: (timelineTitle: string, timelineId: string | null) => void;
}
const InsertTimelinePopoverComponent: React.FC<InsertTimelinePopoverProps> = ({
isDisabled,
hideUntitled = false,
onTimelineChange,
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const handleClosePopover = useCallback(() => {
setIsPopoverOpen(false);
}, []);
const handleOpenPopover = useCallback(() => {
setIsPopoverOpen(true);
}, []);
const insertTimelineButton = useMemo(
() => (
<EuiButtonIcon
aria-label={i18n.INSERT_TIMELINE}
data-test-subj="insert-timeline-button"
iconType="timeline"
isDisabled={isDisabled}
onClick={handleOpenPopover}
/>
),
[handleOpenPopover, isDisabled]
);
const handleGetSelectableOptions = useCallback(
({ timelines }) => [
...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: undefined,
} as EuiSelectableOption)
),
],
[hideUntitled]
);
return (
<EuiPopover
id="searchTimelinePopover"
button={insertTimelineButton}
isOpen={isPopoverOpen}
closePopover={handleClosePopover}
>
<SelectableTimeline
hideUntitled={hideUntitled}
getSelectableOptions={handleGetSelectableOptions}
onClosePopover={handleClosePopover}
onTimelineChange={onTimelineChange}
/>
</EuiPopover>
);
};
export const InsertTimelinePopover = memo(InsertTimelinePopoverComponent);

View file

@ -0,0 +1,44 @@
/*
* 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 { useCallback, useState } from 'react';
import { useBasePath } from '../../../lib/kibana';
import { CursorPosition } from '../../markdown_editor';
import { FormData, FormHook } from '../../../shared_imports';
export const useInsertTimeline = <T extends FormData>(form: FormHook<T>, fieldName: string) => {
const basePath = window.location.origin + useBasePath();
const [cursorPosition, setCursorPosition] = useState<CursorPosition>({
start: 0,
end: 0,
});
const handleOnTimelineChange = useCallback(
(title: string, id: string | null) => {
const builtLink = `${basePath}/app/siem#/timelines?timeline=(id:${id},isOpen:!t)`;
const currentValue = form.getFormData()[fieldName];
const newValue: string = [
currentValue.slice(0, cursorPosition.start),
cursorPosition.start === cursorPosition.end
? `[${title}](${builtLink})`
: `[${currentValue.slice(cursorPosition.start, cursorPosition.end)}](${builtLink})`,
currentValue.slice(cursorPosition.end),
].join('');
form.setFieldValue(fieldName, newValue);
},
[form]
);
const handleCursorChange = useCallback(
(cp: CursorPosition) => {
setCursorPosition(cp);
},
[cursorPosition]
);
return {
cursorPosition,
handleCursorChange,
handleOnTimelineChange,
};
};

View file

@ -4,31 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiHighlight,
EuiInputPopover,
EuiSuperSelect,
EuiSelectable,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiTextColor,
EuiFilterButton,
EuiFilterGroup,
EuiPortal,
EuiSelectableOption,
} from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import { EuiInputPopover, EuiSelectableOption, EuiSuperSelect } from '@elastic/eui';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { ListProps } from 'react-virtualized';
import styled, { createGlobalStyle } from 'styled-components';
import { createGlobalStyle } from 'styled-components';
import { AllTimelinesQuery } from '../../../containers/timeline/all';
import { getEmptyTagValue } from '../../empty_value';
import { isUntitled } from '../../../components/open_timeline/helpers';
import * as i18nTimeline from '../../../components/open_timeline/translations';
import { SortFieldTimeline, Direction } from '../../../graphql/types';
import * as i18n from './translations';
import { OpenTimelineResult } from '../../open_timeline/types';
import { SelectableTimeline } from '../selectable_timeline';
import * as i18n from '../translations';
const SearchTimelineSuperSelectGlobalStyle = createGlobalStyle`
.euiPopover__panel.euiPopover__panel-isOpen.timeline-search-super-select-popover__popoverPanel {
@ -37,40 +19,6 @@ const SearchTimelineSuperSelectGlobalStyle = createGlobalStyle`
}
`;
const MyEuiFlexItem = styled(EuiFlexItem)`
display: inline-block;
max-width: 296px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const EuiSelectableContainer = styled.div<{ loading: boolean }>`
.euiSelectable {
.euiFormControlLayout__childrenWrapper {
display: flex;
}
${({ loading }) => `${
loading
? `
.euiFormControlLayoutIcons {
display: none;
}
.euiFormControlLayoutIcons.euiFormControlLayoutIcons--right {
display: block;
left: 12px;
top: 12px;
}`
: ''
}
`}
}
`;
const MyEuiFlexGroup = styled(EuiFlexGroup)`
padding 0px 4px;
`;
interface SearchTimelineSuperSelectProps {
isDisabled: boolean;
hideUntitled?: boolean;
@ -97,9 +45,6 @@ const getBasicSelectableOptions = (timelineId: string) => [
} as EuiSelectableOption,
];
const ORIGINAL_PAGE_SIZE = 50;
const POPOVER_HEIGHT = 260;
const TIMELINE_ITEM_HEIGHT = 50;
const SearchTimelineSuperSelectComponent: React.FC<SearchTimelineSuperSelectProps> = ({
isDisabled,
hideUntitled = false,
@ -107,16 +52,7 @@ const SearchTimelineSuperSelectComponent: React.FC<SearchTimelineSuperSelectProp
timelineTitle,
onTimelineChange,
}) => {
const [pageSize, setPageSize] = useState(ORIGINAL_PAGE_SIZE);
const [heightTrigger, setHeightTrigger] = useState(0);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [searchTimelineValue, setSearchTimelineValue] = useState('');
const [onlyFavorites, setOnlyFavorites] = useState(false);
const [searchRef, setSearchRef] = useState<HTMLElement | null>(null);
const onSearchTimeline = useCallback(val => {
setSearchTimelineValue(val);
}, []);
const handleClosePopover = useCallback(() => {
setIsPopoverOpen(false);
@ -126,97 +62,6 @@ const SearchTimelineSuperSelectComponent: React.FC<SearchTimelineSuperSelectProp
setIsPopoverOpen(true);
}, []);
const handleOnToggleOnlyFavorites = useCallback(() => {
setOnlyFavorites(!onlyFavorites);
}, [onlyFavorites]);
const renderTimelineOption = useCallback((option, searchValue) => {
return (
<EuiFlexGroup
gutterSize="s"
justifyContent="spaceBetween"
alignItems="center"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiIcon type={`${option.checked === 'on' ? 'check' : 'none'}`} color="primary" />
</EuiFlexItem>
<EuiFlexItem grow={true}>
<EuiFlexGroup gutterSize="none" direction="column">
<MyEuiFlexItem grow={false}>
<EuiHighlight search={searchValue}>
{isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title}
</EuiHighlight>
</MyEuiFlexItem>
<MyEuiFlexItem grow={false}>
<EuiTextColor color="subdued" component="span">
<small>
{option.description != null && option.description.trim().length > 0
? option.description
: getEmptyTagValue()}
</small>
</EuiTextColor>
</MyEuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIcon
type={`${
option.favorite != null && isEmpty(option.favorite) ? 'starEmpty' : 'starFilled'
}`}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}, []);
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
);
}
setIsPopoverOpen(false);
},
[onTimelineChange]
);
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 superSelect = useMemo(
() => (
<EuiSuperSelect
@ -241,27 +86,27 @@ const SearchTimelineSuperSelectComponent: React.FC<SearchTimelineSuperSelectProp
[handleOpenPopover, isDisabled, timelineId, timelineTitle]
);
const favoritePortal = useMemo(
() =>
searchRef != null ? (
<EuiPortal insert={{ sibling: searchRef, position: 'after' }}>
<MyEuiFlexGroup gutterSize="xs" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiFilterGroup>
<EuiFilterButton
size="l"
data-test-subj="only-favorites-toggle"
hasActiveFilters={onlyFavorites}
onClick={handleOnToggleOnlyFavorites}
>
{i18nTimeline.ONLY_FAVORITES}
</EuiFilterButton>
</EuiFilterGroup>
</EuiFlexItem>
</MyEuiFlexGroup>
</EuiPortal>
) : null,
[searchRef, onlyFavorites, handleOnToggleOnlyFavorites]
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 (
@ -271,76 +116,12 @@ const SearchTimelineSuperSelectComponent: React.FC<SearchTimelineSuperSelectProp
isOpen={isPopoverOpen}
closePopover={handleClosePopover}
>
<AllTimelinesQuery
pageInfo={{
pageIndex: 1,
pageSize,
}}
search={searchTimelineValue}
sort={{ sortField: SortFieldTimeline.updated, sortOrder: Direction.desc }}
onlyUserFavorite={onlyFavorites}
>
{({ timelines, loading, totalCount }) => (
<EuiSelectableContainer loading={loading}>
<EuiSelectable
height={POPOVER_HEIGHT}
isLoading={loading && timelines.length === 0}
listProps={{
rowHeight: TIMELINE_ITEM_HEIGHT,
showIcons: false,
virtualizedProps: ({
onScroll: handleOnScroll.bind(
null,
timelines.filter(t => !hideUntitled || t.title !== '').length,
totalCount
),
} as unknown) as ListProps,
}}
renderOption={renderTimelineOption}
onChange={handleTimelineChange}
searchable
searchProps={{
'data-test-subj': 'timeline-super-select-search-box',
isLoading: loading,
placeholder: i18n.SEARCH_BOX_TIMELINE_PLACEHOLDER,
onSearch: onSearchTimeline,
incremental: false,
inputRef: (ref: HTMLElement) => {
setSearchRef(ref);
},
}}
singleSelection={true}
options={[
...(!onlyFavorites && searchTimelineValue === ''
? getBasicSelectableOptions(timelineId == null ? '-1' : timelineId)
: []),
...timelines
.filter(t => !hideUntitled || t.title !== '')
.map(
(t, index) =>
({
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)
),
]}
>
{(list, search) => (
<>
{search}
{favoritePortal}
{list}
</>
)}
</EuiSelectable>
</EuiSelectableContainer>
)}
</AllTimelinesQuery>
<SelectableTimeline
hideUntitled={hideUntitled}
getSelectableOptions={handleGetSelectableOptions}
onClosePopover={handleClosePopover}
onTimelineChange={onTimelineChange}
/>
<SearchTimelineSuperSelectGlobalStyle />
</EuiInputPopover>
);

View file

@ -0,0 +1,276 @@
/*
* 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 } from 'react';
import { ListProps } from 'react-virtualized';
import styled from 'styled-components';
import { AllTimelinesQuery } from '../../../containers/timeline/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 '../../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;
}`
: ''
}
`}
}
`;
const ORIGINAL_PAGE_SIZE = 50;
const POPOVER_HEIGHT = 260;
const TIMELINE_ITEM_HEIGHT = 50;
export interface GetSelectableOptions {
timelines: OpenTimelineResult[];
onlyFavorites: boolean;
searchTimelineValue: string;
}
interface SelectableTimelineProps {
hideUntitled?: boolean;
getSelectableOptions: ({
timelines,
onlyFavorites,
searchTimelineValue,
}: GetSelectableOptions) => EuiSelectableOption[];
onClosePopover: () => void;
onTimelineChange: (timelineTitle: string, timelineId: string | null) => void;
}
const SelectableTimelineComponent: React.FC<SelectableTimelineProps> = ({
hideUntitled = false,
getSelectableOptions,
onClosePopover,
onTimelineChange,
}) => {
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<HTMLElement | null>(null);
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 (
<EuiFlexGroup
gutterSize="s"
justifyContent="spaceBetween"
alignItems="center"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiIcon type={`${option.checked === 'on' ? 'check' : 'none'}`} color="primary" />
</EuiFlexItem>
<EuiFlexItem grow={true}>
<EuiFlexGroup gutterSize="none" direction="column">
<MyEuiFlexItem grow={false}>
<EuiHighlight search={searchValue}>
{isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title}
</EuiHighlight>
</MyEuiFlexItem>
<MyEuiFlexItem grow={false}>
<EuiTextColor color="subdued" component="span">
<small>
{option.description != null && option.description.trim().length > 0
? option.description
: getEmptyTagValue()}
</small>
</EuiTextColor>
</MyEuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIcon
type={`${
option.favorite != null && isEmpty(option.favorite) ? 'starEmpty' : 'starFilled'
}`}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}, []);
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
);
}
onClosePopover();
},
[onClosePopover, onTimelineChange]
);
const favoritePortal = useMemo(
() =>
searchRef != null ? (
<EuiPortal insert={{ sibling: searchRef, position: 'after' }}>
<MyEuiFlexGroup gutterSize="xs" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiFilterGroup>
<EuiFilterButton
size="l"
data-test-subj="only-favorites-toggle"
hasActiveFilters={onlyFavorites}
onClick={handleOnToggleOnlyFavorites}
>
{i18nTimeline.ONLY_FAVORITES}
</EuiFilterButton>
</EuiFilterGroup>
</EuiFlexItem>
</MyEuiFlexGroup>
</EuiPortal>
) : null,
[searchRef, onlyFavorites, handleOnToggleOnlyFavorites]
);
return (
<>
<AllTimelinesQuery
pageInfo={{
pageIndex: 1,
pageSize,
}}
search={searchTimelineValue}
sort={{ sortField: SortFieldTimeline.updated, sortOrder: Direction.desc }}
onlyUserFavorite={onlyFavorites}
>
{({ timelines, loading, totalCount }) => (
<EuiSelectableContainer isLoading={loading}>
<EuiSelectable
height={POPOVER_HEIGHT}
isLoading={loading && timelines.length === 0}
listProps={{
rowHeight: TIMELINE_ITEM_HEIGHT,
showIcons: false,
virtualizedProps: ({
onScroll: handleOnScroll.bind(
null,
timelines.filter(t => !hideUntitled || t.title !== '').length,
totalCount
),
} as unknown) as ListProps,
}}
renderOption={renderTimelineOption}
onChange={handleTimelineChange}
searchable
searchProps={{
'data-test-subj': 'timeline-super-select-search-box',
isLoading: loading,
placeholder: i18n.SEARCH_BOX_TIMELINE_PLACEHOLDER,
onSearch: onSearchTimeline,
incremental: false,
inputRef: (ref: HTMLElement) => {
setSearchRef(ref);
},
}}
singleSelection={true}
options={getSelectableOptions({ timelines, onlyFavorites, searchTimelineValue })}
>
{(list, search) => (
<>
{search}
{favoritePortal}
{list}
</>
)}
</EuiSelectable>
</EuiSelectableContainer>
)}
</AllTimelinesQuery>
</>
);
};
export const SelectableTimeline = memo(SelectableTimelineComponent);

View file

@ -23,3 +23,7 @@ export const SEARCH_BOX_TIMELINE_PLACEHOLDER = i18n.translate(
defaultMessage: 'e.g. timeline name or description',
}
);
export const INSERT_TIMELINE = i18n.translate('xpack.siem.insert.timeline.insertTimelineButton', {
defaultMessage: 'Insert Timeline…',
});

View file

@ -105,13 +105,17 @@ export const postComment = async (newComment: CommentRequest, caseId: string): P
};
export const patchComment = async (
caseId: string,
commentId: string,
commentUpdate: string,
version: string
): Promise<Partial<Comment>> => {
const response = await KibanaServices.get().http.fetch<CommentResponse>(`${CASES_URL}/comments`, {
method: 'PATCH',
body: JSON.stringify({ comment: commentUpdate, id: commentId, version }),
});
const response = await KibanaServices.get().http.fetch<CommentResponse>(
`${CASES_URL}/${caseId}/comments`,
{
method: 'PATCH',
body: JSON.stringify({ comment: commentUpdate, id: commentId, version }),
}
);
return convertToCamelCase<CommentResponse, Comment>(decodeCommentResponse(response));
};

View file

@ -69,7 +69,7 @@ const dataFetchReducer = (state: CommentUpdateState, action: Action): CommentUpd
};
interface UseUpdateComment extends CommentUpdateState {
updateComment: (commentId: string, commentUpdate: string) => void;
updateComment: (caseId: string, commentId: string, commentUpdate: string) => void;
}
export const useUpdateComment = (comments: Comment[]): UseUpdateComment => {
@ -81,14 +81,19 @@ export const useUpdateComment = (comments: Comment[]): UseUpdateComment => {
const [, dispatchToaster] = useStateToaster();
const dispatchUpdateComment = useCallback(
async (commentId: string, commentUpdate: string) => {
async (caseId: string, commentId: string, commentUpdate: string) => {
let cancel = false;
try {
dispatch({ type: FETCH_INIT, payload: commentId });
const currentComment = state.comments.find(comment => comment.id === commentId) ?? {
version: '',
};
const response = await patchComment(commentId, commentUpdate, currentComment.version);
const response = await patchComment(
caseId,
commentId,
commentUpdate,
currentComment.version
);
if (!cancel) {
dispatch({ type: FETCH_SUCCESS, payload: { update: response, commentId } });
}

View file

@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButton, EuiLoadingSpinner } from '@elastic/eui';
import React, { useCallback } from 'react';
import { EuiButton, EuiLoadingSpinner } from '@elastic/eui';
import styled from 'styled-components';
import { CommentRequest } from '../../../../../../../../plugins/case/common/api';
@ -14,6 +14,8 @@ import { MarkdownEditorForm } from '../../../../components/markdown_editor/form'
import { Form, useForm, UseField } from '../../../../shared_imports';
import * as i18n from '../../translations';
import { schema } from './schema';
import { InsertTimelinePopover } from '../../../../components/timeline/insert_timeline_popover';
import { useInsertTimeline } from '../../../../components/timeline/insert_timeline_popover/use_insert_timeline';
const MySpinner = styled(EuiLoadingSpinner)`
position: absolute;
@ -34,7 +36,10 @@ export const AddComment = React.memo<{
options: { stripEmptyFields: false },
schema,
});
const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline<CommentRequest>(
form,
'comment'
);
const onSubmit = useCallback(async () => {
const { isValid, data } = await form.submit();
if (isValid) {
@ -54,7 +59,8 @@ export const AddComment = React.memo<{
isDisabled: isLoading,
dataTestSubj: 'caseComment',
placeholder: i18n.ADD_COMMENT_HELP_TEXT,
footerContentRight: (
onCursorPositionUpdate: handleCursorChange,
bottomRightContent: (
<EuiButton
iconType="plusInCircle"
isDisabled={isLoading}
@ -65,6 +71,13 @@ export const AddComment = React.memo<{
{i18n.ADD_COMMENT}
</EuiButton>
),
topRightContent: (
<InsertTimelinePopover
hideUntitled={true}
isDisabled={isLoading}
onTimelineChange={handleOnTimelineChange}
/>
),
}}
/>
</Form>

View file

@ -11,6 +11,8 @@ import { AllCases } from './';
import { TestProviders } from '../../../../mock';
import { useGetCasesMockState } from './__mock__';
import * as apiHook from '../../../../containers/case/use_get_cases';
import { act } from '@testing-library/react';
import { wait } from '../../../../lib/helpers';
describe('AllCases', () => {
const setFilters = jest.fn();
@ -30,12 +32,13 @@ describe('AllCases', () => {
});
moment.tz.setDefault('UTC');
});
it('should render AllCases', () => {
it('should render AllCases', async () => {
const wrapper = mount(
<TestProviders>
<AllCases />
</TestProviders>
);
await act(() => wait());
expect(
wrapper
.find(`a[data-test-subj="case-details-link"]`)
@ -73,12 +76,13 @@ describe('AllCases', () => {
.text()
).toEqual('Showing 10 cases');
});
it('should tableHeaderSortButton AllCases', () => {
it('should tableHeaderSortButton AllCases', async () => {
const wrapper = mount(
<TestProviders>
<AllCases />
</TestProviders>
);
await act(() => wait());
wrapper
.find('[data-test-subj="tableHeaderSortButton"]')
.first()

View file

@ -19,6 +19,8 @@ import { CaseRequest } from '../../../../../../../../plugins/case/common/api';
import { Field, Form, getUseField, useForm, UseField } from '../../../../shared_imports';
import { usePostCase } from '../../../../containers/case/use_post_case';
import { schema } from './schema';
import { InsertTimelinePopover } from '../../../../components/timeline/insert_timeline_popover';
import { useInsertTimeline } from '../../../../components/timeline/insert_timeline_popover/use_insert_timeline';
import * as i18n from '../../translations';
import { SiemPageName } from '../../../home/types';
import { MarkdownEditorForm } from '../../../../components/markdown_editor/form';
@ -58,6 +60,10 @@ export const Create = React.memo(() => {
options: { stripEmptyFields: false },
schema,
});
const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline<CaseRequest>(
form,
'description'
);
const onSubmit = useCallback(async () => {
const { isValid, data } = await form.submit();
@ -108,9 +114,17 @@ export const Create = React.memo(() => {
path="description"
component={MarkdownEditorForm}
componentProps={{
idAria: 'caseDescription',
dataTestSubj: 'caseDescription',
idAria: 'caseDescription',
isDisabled: isLoading,
onCursorPositionUpdate: handleCursorChange,
topRightContent: (
<InsertTimelinePopover
hideUntitled={true}
isDisabled={isLoading}
onTimelineChange={handleOnTimelineChange}
/>
),
}}
/>
</ContainerBig>

View file

@ -42,7 +42,7 @@ export const UserActionTree = React.memo(
const handleSaveComment = useCallback(
(id: string, content: string) => {
handleManageMarkdownEditId(id);
updateComment(id, content);
updateComment(caseData.id, id, content);
},
[handleManageMarkdownEditId, updateComment]
);

View file

@ -0,0 +1,23 @@
/*
* 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 { FIELD_TYPES, fieldValidators, FormSchema } from '../../../../shared_imports';
import * as i18n from '../../translations';
const { emptyField } = fieldValidators;
export interface Content {
content: string;
}
export const schema: FormSchema<Content> = {
content: {
type: FIELD_TYPES.TEXTAREA,
validations: [
{
validator: emptyField(i18n.REQUIRED_FIELD),
},
],
},
};

View file

@ -5,12 +5,16 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui';
import React, { useCallback, useState } from 'react';
import React, { useCallback } from 'react';
import styled, { css } from 'styled-components';
import { MarkdownEditor } from '../../../../components/markdown_editor';
import * as i18n from '../case_view/translations';
import { Markdown } from '../../../../components/markdown';
import { Form, useForm, UseField } from '../../../../shared_imports';
import { schema, Content } from './schema';
import { InsertTimelinePopover } from '../../../../components/timeline/insert_timeline_popover';
import { useInsertTimeline } from '../../../../components/timeline/insert_timeline_popover/use_insert_timeline';
import { MarkdownEditorForm } from '../../../../components/markdown_editor/form';
const ContentWrapper = styled.div`
${({ theme }) => css`
@ -25,7 +29,6 @@ interface UserActionMarkdownProps {
onChangeEditable: (id: string) => void;
onSaveContent: (content: string) => void;
}
export const UserActionMarkdown = ({
id,
content,
@ -33,24 +36,26 @@ export const UserActionMarkdown = ({
onChangeEditable,
onSaveContent,
}: UserActionMarkdownProps) => {
const [myContent, setMyContent] = useState(content);
const { form } = useForm<Content>({
defaultValue: { content },
options: { stripEmptyFields: false },
schema,
});
const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline<Content>(
form,
'content'
);
const handleCancelAction = useCallback(() => {
onChangeEditable(id);
}, [id, onChangeEditable]);
const handleSaveAction = useCallback(() => {
if (myContent !== content) {
onSaveContent(content);
const handleSaveAction = useCallback(async () => {
const { isValid, data } = await form.submit();
if (isValid) {
onSaveContent(data.content);
}
onChangeEditable(id);
}, [content, id, myContent, onChangeEditable, onSaveContent]);
const handleOnChange = useCallback(() => {
if (myContent !== content) {
setMyContent(content);
}
}, [content, myContent]);
}, [form, id, onChangeEditable, onSaveContent]);
const renderButtons = useCallback(
({ cancelAction, saveAction }) => {
@ -71,16 +76,27 @@ export const UserActionMarkdown = ({
},
[handleCancelAction, handleSaveAction]
);
return isEditable ? (
<MarkdownEditor
footerContentRight={renderButtons({
cancelAction: handleCancelAction,
saveAction: handleSaveAction,
})}
initialContent={content}
onChange={handleOnChange}
/>
<Form form={form}>
<UseField
path="content"
component={MarkdownEditorForm}
componentProps={{
bottomRightContent: renderButtons({
cancelAction: handleCancelAction,
saveAction: handleSaveAction,
}),
onCursorPositionUpdate: handleCursorChange,
topRightContent: (
<InsertTimelinePopover
hideUntitled={true}
isDisabled={false}
onTimelineChange={handleOnTimelineChange}
/>
),
}}
/>
</Form>
) : (
<ContentWrapper>
<Markdown raw={content} data-test-subj="case-view-description" />

View file

@ -52,6 +52,10 @@ export const COMMENT_REQUIRED = i18n.translate(
}
);
export const REQUIRED_FIELD = i18n.translate('xpack.siem.case.caseView.fieldRequiredError', {
defaultMessage: 'Required field',
});
export const EDIT = i18n.translate('xpack.siem.case.caseView.edit', {
defaultMessage: 'Edit',
});

View file

@ -15,7 +15,7 @@ import {
FilterManager,
Query,
} from '../../../../../../../../../../src/plugins/data/public';
import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/search_super_select/translations';
import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations';
import { useKibana } from '../../../../../lib/kibana';
import { IMitreEnterpriseAttack } from '../../types';
import { FieldValueTimeline } from '../pick_timeline';

View file

@ -5,7 +5,7 @@
*/
import { AboutStepRule } from '../../types';
import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/search_super_select/translations';
import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations';
export const threatDefault = [
{

View file

@ -61,7 +61,7 @@ export function initPatchCommentApi({ caseService, router }: RouteDeps) {
client: context.core.savedObjects.client,
commentId: query.id,
updatedAttributes: {
...query,
comment: query.comment,
updated_at: new Date().toISOString(),
updated_by: { full_name, username },
},