[SIEM] [Case] Insert timeline into case textarea (#59586)
This commit is contained in:
parent
8bc051ac49
commit
ecac63f258
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
|
@ -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…',
|
||||
});
|
|
@ -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));
|
||||
};
|
||||
|
|
|
@ -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 } });
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
|
@ -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" />
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
@ -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 },
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue