[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|
|---|---|
|![Bildschirmfoto 2024-07-11 um 17 48
15](2b45d3a9-6f1a-4f05-8824-10e2c6265266)|
![Bildschirmfoto 2024-07-11 um 17 46
01](b02c06ff-f556-4894-a588-a88bcdd8bc8c)|


## 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:
Jatin Kathuria 2024-07-12 19:20:19 +02:00 committed by GitHub
parent 9440ea5071
commit 309b907e59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 244 additions and 59 deletions

View file

@ -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}

View file

@ -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 = {

View file

@ -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(

View file

@ -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>
);
});

View file

@ -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);
});
});

View file

@ -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(

View file

@ -162,6 +162,7 @@ export const EqlTabContentComponent: React.FC<Props> = ({
eventIdToNoteIds,
refetch,
timelineId,
activeTab: TimelineTabs.eql,
});
const onToggleShowNotes = useCallback(

View file

@ -198,6 +198,7 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
eventIdToNoteIds,
refetch,
timelineId,
activeTab: TimelineTabs.pinned,
});
const onToggleShowNotes = useCallback(

View file

@ -228,6 +228,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({
eventIdToNoteIds,
refetch,
timelineId,
activeTab,
});
const onToggleShowNotes = useCallback(

View file

@ -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
);
});
});