mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Security Solution] Fix - Notes Flyout Product Feedback (#188129)
# Summary Fixes below bugs based on feedback from @paulewing. ## Event Details Toggle in Notes @paulewing requested to remove the event toggle |Before|After| |---|---| || | ## Notes Flyout remains open when switching tabs |Before|After| |---|---| |<video src="4228d2d6
-c2ad-40dc-9e6c-ec049f834e8f" />|<video src="0e010c22
-4539-4428-9b1b-3b323a9f491c" />| ## Notes Flyout should be resizable As shown in above video, notes flyout is now resizable.
This commit is contained in:
parent
9440ea5071
commit
309b907e59
10 changed files with 244 additions and 59 deletions
|
@ -51,6 +51,7 @@ export interface NoteCardsProps {
|
|||
eventId?: string;
|
||||
timelineId: string;
|
||||
onCancel?: () => void;
|
||||
showToggleEventDetailsAction?: boolean;
|
||||
}
|
||||
|
||||
/** A view for entering and reviewing notes */
|
||||
|
@ -65,6 +66,7 @@ export const NoteCards = React.memo<NoteCardsProps>(
|
|||
eventId,
|
||||
timelineId,
|
||||
onCancel,
|
||||
showToggleEventDetailsAction = true,
|
||||
}) => {
|
||||
const [newNote, setNewNote] = useState('');
|
||||
|
||||
|
@ -109,7 +111,11 @@ export const NoteCards = React.memo<NoteCardsProps>(
|
|||
<EuiScreenReaderOnly data-test-subj="screenReaderOnly">
|
||||
<p>{i18n.YOU_ARE_VIEWING_NOTES(ariaRowindex)}</p>
|
||||
</EuiScreenReaderOnly>
|
||||
<NotePreviews timelineId={timelineId} notes={notes} />
|
||||
<NotePreviews
|
||||
timelineId={timelineId}
|
||||
notes={notes}
|
||||
showToggleEventDetailsAction={showToggleEventDetailsAction}
|
||||
/>
|
||||
</NotesContainer>
|
||||
</NotePreviewsContainer>
|
||||
) : null}
|
||||
|
|
|
@ -239,6 +239,63 @@ describe('NotePreviews', () => {
|
|||
expect(wrapper.find('[data-test-subj="delete-note"] button').prop('disabled')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should render toggle event details action by default', () => {
|
||||
const timeline = mockTimelineResults[0];
|
||||
(useDeepEqualSelector as jest.Mock).mockReturnValue(timeline);
|
||||
|
||||
const wrapper = mountWithI18nProvider(
|
||||
<TestProviders>
|
||||
<NotePreviews
|
||||
notes={[
|
||||
{
|
||||
noteId: 'noteId1',
|
||||
note: 'enabled delete',
|
||||
savedObjectId: 'test-id',
|
||||
updated: note2updated,
|
||||
updatedBy: 'alice',
|
||||
},
|
||||
]}
|
||||
showTimelineDescription
|
||||
timelineId="test-timeline-id"
|
||||
/>
|
||||
</TestProviders>,
|
||||
{
|
||||
wrappingComponent: createReactQueryWrapper(),
|
||||
}
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="notes-toggle-event-details"]').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should not render toggle event details action when showToggleEventDetailsAction is false ', () => {
|
||||
const timeline = mockTimelineResults[0];
|
||||
(useDeepEqualSelector as jest.Mock).mockReturnValue(timeline);
|
||||
|
||||
const wrapper = mountWithI18nProvider(
|
||||
<TestProviders>
|
||||
<NotePreviews
|
||||
notes={[
|
||||
{
|
||||
noteId: 'noteId1',
|
||||
note: 'enabled delete',
|
||||
savedObjectId: 'test-id',
|
||||
updated: note2updated,
|
||||
updatedBy: 'alice',
|
||||
},
|
||||
]}
|
||||
showTimelineDescription
|
||||
timelineId="test-timeline-id"
|
||||
showToggleEventDetailsAction={false}
|
||||
/>
|
||||
</TestProviders>,
|
||||
{
|
||||
wrappingComponent: createReactQueryWrapper(),
|
||||
}
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="notes-toggle-event-details"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
describe('Delete Notes', () => {
|
||||
it('should delete note correctly', async () => {
|
||||
const timeline = {
|
||||
|
|
|
@ -74,6 +74,7 @@ const ToggleEventDetailsButtonComponent: React.FC<ToggleEventDetailsButtonProps>
|
|||
|
||||
return (
|
||||
<EuiButtonIcon
|
||||
data-test-subj="notes-toggle-event-details"
|
||||
title={i18n.TOGGLE_EXPAND_EVENT_DETAILS}
|
||||
aria-label={i18n.TOGGLE_EXPAND_EVENT_DETAILS}
|
||||
color="text"
|
||||
|
@ -177,10 +178,32 @@ const NoteActions = React.memo<{
|
|||
savedObjectId?: string | null;
|
||||
confirmingNoteId?: string | null;
|
||||
eventIdToNoteIds?: Record<string, string[]>;
|
||||
}>(({ eventId, timelineId, noteId, confirmingNoteId, eventIdToNoteIds, savedObjectId }) => {
|
||||
return eventId && timelineId ? (
|
||||
<>
|
||||
<ToggleEventDetailsButton eventId={eventId} timelineId={timelineId} />
|
||||
showToggleEventDetailsAction?: boolean;
|
||||
}>(
|
||||
({
|
||||
eventId,
|
||||
timelineId,
|
||||
noteId,
|
||||
confirmingNoteId,
|
||||
eventIdToNoteIds,
|
||||
savedObjectId,
|
||||
showToggleEventDetailsAction = true,
|
||||
}) => {
|
||||
return eventId && timelineId ? (
|
||||
<>
|
||||
{showToggleEventDetailsAction ? (
|
||||
<ToggleEventDetailsButton eventId={eventId} timelineId={timelineId} />
|
||||
) : null}
|
||||
<DeleteNoteButton
|
||||
noteId={noteId}
|
||||
eventId={eventId}
|
||||
confirmingNoteId={confirmingNoteId}
|
||||
savedObjectId={savedObjectId}
|
||||
timelineId={timelineId}
|
||||
eventIdToNoteIds={eventIdToNoteIds}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<DeleteNoteButton
|
||||
noteId={noteId}
|
||||
eventId={eventId}
|
||||
|
@ -189,18 +212,9 @@ const NoteActions = React.memo<{
|
|||
timelineId={timelineId}
|
||||
eventIdToNoteIds={eventIdToNoteIds}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<DeleteNoteButton
|
||||
noteId={noteId}
|
||||
eventId={eventId}
|
||||
confirmingNoteId={confirmingNoteId}
|
||||
savedObjectId={savedObjectId}
|
||||
timelineId={timelineId}
|
||||
eventIdToNoteIds={eventIdToNoteIds}
|
||||
/>
|
||||
);
|
||||
});
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
NoteActions.displayName = 'NoteActions';
|
||||
/**
|
||||
|
@ -211,10 +225,11 @@ interface NotePreviewsProps {
|
|||
notes?: TimelineResultNote[] | null;
|
||||
timelineId?: string;
|
||||
showTimelineDescription?: boolean;
|
||||
showToggleEventDetailsAction?: boolean;
|
||||
}
|
||||
|
||||
export const NotePreviews = React.memo<NotePreviewsProps>(
|
||||
({ notes, timelineId, showTimelineDescription }) => {
|
||||
({ notes, timelineId, showTimelineDescription, showToggleEventDetailsAction = true }) => {
|
||||
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
|
||||
const getTimelineNotes = useMemo(() => getTimelineNoteSelector(), []);
|
||||
const timeline = useDeepEqualSelector((state) =>
|
||||
|
@ -288,6 +303,7 @@ export const NotePreviews = React.memo<NotePreviewsProps>(
|
|||
savedObjectId={note.savedObjectId}
|
||||
confirmingNoteId={timeline?.confirmingNoteId}
|
||||
eventIdToNoteIds={eventIdToNoteIds}
|
||||
showToggleEventDetailsAction={showToggleEventDetailsAction}
|
||||
/>
|
||||
),
|
||||
timelineAvatar: (
|
||||
|
@ -299,7 +315,13 @@ export const NotePreviews = React.memo<NotePreviewsProps>(
|
|||
),
|
||||
};
|
||||
}),
|
||||
[eventIdToNoteIds, notes, timelineId, timeline?.confirmingNoteId]
|
||||
[
|
||||
eventIdToNoteIds,
|
||||
notes,
|
||||
timelineId,
|
||||
timeline?.confirmingNoteId,
|
||||
showToggleEventDetailsAction,
|
||||
]
|
||||
);
|
||||
|
||||
const commentList = useMemo(
|
||||
|
|
|
@ -7,9 +7,10 @@
|
|||
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutHeader,
|
||||
EuiFlyoutResizable,
|
||||
EuiOutsideClickDetector,
|
||||
EuiTitle,
|
||||
useGeneratedHtmlId,
|
||||
} from '@elastic/eui';
|
||||
|
@ -32,7 +33,7 @@ export type NotesFlyoutProps = {
|
|||
* z-index override is needed because otherwise NotesFlyout appears below
|
||||
* Timeline Modal as they both have same z-index of 1000
|
||||
*/
|
||||
const NotesFlyoutContainer = styled(EuiFlyout)`
|
||||
const NotesFlyoutContainer = styled(EuiFlyoutResizable)`
|
||||
/*
|
||||
* We want the width of flyout to be less than 50% of screen because
|
||||
* otherwise it interferes with the delete notes modal
|
||||
|
@ -55,33 +56,37 @@ export const NotesFlyout = React.memo(function NotesFlyout(props: NotesFlyoutPro
|
|||
}
|
||||
|
||||
return (
|
||||
<NotesFlyoutContainer
|
||||
ownFocus={false}
|
||||
className="timeline-notes-flyout"
|
||||
data-test-subj="timeline-notes-flyout"
|
||||
onClose={onClose}
|
||||
aria-labelledby={notesFlyoutTitleId}
|
||||
maxWidth={750}
|
||||
>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="m">
|
||||
<h2>{i18n.NOTES}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<NoteCards
|
||||
ariaRowindex={0}
|
||||
associateNote={associateNote}
|
||||
className="notes-in-flyout"
|
||||
data-test-subj="note-cards"
|
||||
notes={notes}
|
||||
showAddNote={true}
|
||||
toggleShowAddNote={toggleShowAddNote}
|
||||
eventId={eventId}
|
||||
timelineId={timelineId}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</EuiFlyoutBody>
|
||||
</NotesFlyoutContainer>
|
||||
<EuiOutsideClickDetector onOutsideClick={onClose}>
|
||||
<NotesFlyoutContainer
|
||||
ownFocus={false}
|
||||
className="timeline-notes-flyout"
|
||||
data-test-subj="timeline-notes-flyout"
|
||||
onClose={onClose}
|
||||
aria-labelledby={notesFlyoutTitleId}
|
||||
minWidth={500}
|
||||
maxWidth={1400}
|
||||
>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="m">
|
||||
<h2>{i18n.NOTES}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<NoteCards
|
||||
ariaRowindex={0}
|
||||
associateNote={associateNote}
|
||||
className="notes-in-flyout"
|
||||
data-test-subj="note-cards"
|
||||
notes={notes}
|
||||
showAddNote={true}
|
||||
toggleShowAddNote={toggleShowAddNote}
|
||||
eventId={eventId}
|
||||
timelineId={timelineId}
|
||||
onCancel={onCancel}
|
||||
showToggleEventDetailsAction={false}
|
||||
/>
|
||||
</EuiFlyoutBody>
|
||||
</NotesFlyoutContainer>
|
||||
</EuiOutsideClickDetector>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -6,9 +6,10 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { TimelineId } from '../../../../../common/types';
|
||||
import { TimelineId, TimelineTabs } from '../../../../../common/types';
|
||||
import { renderHook, act } from '@testing-library/react-hooks/dom';
|
||||
import { createMockStore, mockGlobalState, TestProviders } from '../../../../common/mock';
|
||||
import type { UseNotesInFlyoutArgs } from './use_notes_in_flyout';
|
||||
import { useNotesInFlyout } from './use_notes_in_flyout';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
@ -85,11 +86,13 @@ const refetchMock = jest.fn();
|
|||
|
||||
const renderTestHook = () => {
|
||||
return renderHook(
|
||||
() =>
|
||||
(props?: Partial<UseNotesInFlyoutArgs>) =>
|
||||
useNotesInFlyout({
|
||||
eventIdToNoteIds: mockEventIdToNoteIds,
|
||||
timelineId: TimelineId.test,
|
||||
refetch: refetchMock,
|
||||
activeTab: TimelineTabs.query,
|
||||
...props,
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
|
@ -198,4 +201,33 @@ describe('useNotesInFlyout', () => {
|
|||
|
||||
expect(result.current.isNotesFlyoutVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should close the flyout when activeTab is changed', () => {
|
||||
const { result, rerender, waitForNextUpdate } = renderTestHook();
|
||||
|
||||
act(() => {
|
||||
result.current.setNotesEventId('event-1');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.showNotesFlyout();
|
||||
});
|
||||
|
||||
expect(result.current.isNotesFlyoutVisible).toBe(true);
|
||||
|
||||
act(() => {
|
||||
// no change in active Tab
|
||||
rerender({ activeTab: TimelineTabs.query });
|
||||
});
|
||||
|
||||
expect(result.current.isNotesFlyoutVisible).toBe(true);
|
||||
|
||||
act(() => {
|
||||
rerender({ activeTab: TimelineTabs.eql });
|
||||
});
|
||||
|
||||
waitForNextUpdate();
|
||||
|
||||
expect(result.current.isNotesFlyoutVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,16 +5,18 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import type { TimelineTabs } from '../../../../../common/types';
|
||||
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
|
||||
import { appSelectors } from '../../../../common/store';
|
||||
import { timelineActions } from '../../../store';
|
||||
|
||||
interface UseNotesInFlyoutArgs {
|
||||
export interface UseNotesInFlyoutArgs {
|
||||
eventIdToNoteIds: Record<string, string[]>;
|
||||
refetch?: () => void;
|
||||
timelineId: string;
|
||||
activeTab: TimelineTabs;
|
||||
}
|
||||
|
||||
const EMPTY_STRING_ARRAY: string[] = [];
|
||||
|
@ -36,12 +38,19 @@ export const useNotesInFlyout = (args: UseNotesInFlyoutArgs) => {
|
|||
setIsNotesFlyoutVisible(true);
|
||||
}, []);
|
||||
|
||||
const { eventIdToNoteIds, refetch, timelineId } = args;
|
||||
const { eventIdToNoteIds, refetch, timelineId, activeTab } = args;
|
||||
|
||||
const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []);
|
||||
|
||||
const notesById = useDeepEqualSelector(getNotesByIds);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab) {
|
||||
// if activeTab changes, close the notes flyout
|
||||
closeNotesFlyout();
|
||||
}
|
||||
}, [activeTab, closeNotesFlyout]);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const noteIds: string[] = useMemo(
|
||||
|
|
|
@ -162,6 +162,7 @@ export const EqlTabContentComponent: React.FC<Props> = ({
|
|||
eventIdToNoteIds,
|
||||
refetch,
|
||||
timelineId,
|
||||
activeTab: TimelineTabs.eql,
|
||||
});
|
||||
|
||||
const onToggleShowNotes = useCallback(
|
||||
|
|
|
@ -198,6 +198,7 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
|
|||
eventIdToNoteIds,
|
||||
refetch,
|
||||
timelineId,
|
||||
activeTab: TimelineTabs.pinned,
|
||||
});
|
||||
|
||||
const onToggleShowNotes = useCallback(
|
||||
|
|
|
@ -228,6 +228,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({
|
|||
eventIdToNoteIds,
|
||||
refetch,
|
||||
timelineId,
|
||||
activeTab,
|
||||
});
|
||||
|
||||
const onToggleShowNotes = useCallback(
|
||||
|
|
|
@ -27,7 +27,6 @@ import { createStartServicesMock } from '../../../../../common/lib/kibana/kibana
|
|||
import type { StartServices } from '../../../../../types';
|
||||
import { useKibana } from '../../../../../common/lib/kibana';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { timelineActions } from '../../../../store';
|
||||
import type { ExperimentalFeatures } from '../../../../../../common';
|
||||
import { allowedExperimentalValues } from '../../../../../../common';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
|
||||
|
@ -35,7 +34,7 @@ import { defaultUdtHeaders } from '../../unified_components/default_headers';
|
|||
import { defaultColumnHeaderType } from '../../body/column_headers/default_headers';
|
||||
import { useUserPrivileges } from '../../../../../common/components/user_privileges';
|
||||
import { getEndpointPrivilegesInitialStateMock } from '../../../../../common/components/user_privileges/endpoint/mocks';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as timelineActions from '../../../../store/actions';
|
||||
|
||||
jest.mock('../../../../../common/components/user_privileges');
|
||||
|
||||
|
@ -172,6 +171,10 @@ describe('query tab with unified timeline', () => {
|
|||
});
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, '__@hello-pangea/dnd-disable-dev-warnings', {
|
||||
value: true,
|
||||
writable: false,
|
||||
});
|
||||
useTimelineEventsMock = jest.fn(() => [
|
||||
false,
|
||||
{
|
||||
|
@ -891,7 +894,7 @@ describe('query tab with unified timeline', () => {
|
|||
);
|
||||
|
||||
it(
|
||||
'should be cancel adding notes',
|
||||
'should cancel adding notes',
|
||||
async () => {
|
||||
renderTestComponents();
|
||||
expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
|
||||
|
@ -906,8 +909,6 @@ describe('query tab with unified timeline', () => {
|
|||
expect(screen.getByTestId('add-note-container')).toBeVisible();
|
||||
});
|
||||
|
||||
userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), 'Test Note 1');
|
||||
|
||||
expect(screen.getByTestId('cancel')).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel'));
|
||||
|
@ -918,6 +919,56 @@ describe('query tab with unified timeline', () => {
|
|||
},
|
||||
SPECIAL_TEST_TIMEOUT
|
||||
);
|
||||
|
||||
it(
|
||||
'should be able to delete notes',
|
||||
async () => {
|
||||
renderTestComponents();
|
||||
expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('timeline-notes-button-small'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-note')).toBeVisible();
|
||||
});
|
||||
|
||||
const noteDeleteSpy = jest.spyOn(timelineActions, 'setConfirmingNoteId');
|
||||
|
||||
fireEvent.click(screen.getByTestId('delete-note'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(noteDeleteSpy).toHaveBeenCalled();
|
||||
expect(noteDeleteSpy).toHaveBeenCalledWith({
|
||||
confirmingNoteId: '1',
|
||||
id: TimelineId.test,
|
||||
});
|
||||
});
|
||||
},
|
||||
SPECIAL_TEST_TIMEOUT
|
||||
);
|
||||
|
||||
it(
|
||||
'should not show toggle event details action',
|
||||
async () => {
|
||||
renderTestComponents();
|
||||
expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('timeline-notes-button-small'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('notes-toggle-event-details')).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
SPECIAL_TEST_TIMEOUT
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue