mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
# Backport This will backport the following commits from `main` to `8.x`: - [[Security Solution][Notes] - allow filtering by user (#195519)](https://github.com/elastic/kibana/pull/195519) <!--- Backport version: 9.4.3 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Philippe Oberti","email":"philippe.oberti@elastic.co"},"sourceCommit":{"committedDate":"2024-10-16T02:42:23Z","message":"[Security Solution][Notes] - allow filtering by user (#195519)","sha":"d85b51db222f29efbd2d8f32067a13b4932feba8","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["backport","release_note:skip","v9.0.0","Team:Threat Hunting:Investigations","v8.16.0"],"title":"[Security Solution][Notes] - allow filtering by user","number":195519,"url":"https://github.com/elastic/kibana/pull/195519","mergeCommit":{"message":"[Security Solution][Notes] - allow filtering by user (#195519)","sha":"d85b51db222f29efbd2d8f32067a13b4932feba8"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/195519","number":195519,"mergeCommit":{"message":"[Security Solution][Notes] - allow filtering by user (#195519)","sha":"d85b51db222f29efbd2d8f32067a13b4932feba8"}},{"branch":"8.x","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> Co-authored-by: Philippe Oberti <philippe.oberti@elastic.co>
This commit is contained in:
parent
b4fc47aba9
commit
85145569bd
21 changed files with 309 additions and 64 deletions
|
@ -15011,6 +15011,11 @@ paths:
|
|||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
- in: query
|
||||
name: userFilter
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
|
|
|
@ -15011,6 +15011,11 @@ paths:
|
|||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
- in: query
|
||||
name: userFilter
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
|
|
|
@ -18441,6 +18441,11 @@ paths:
|
|||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
- in: query
|
||||
name: userFilter
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
|
|
|
@ -18441,6 +18441,11 @@ paths:
|
|||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
- in: query
|
||||
name: userFilter
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
|
|
|
@ -40,6 +40,7 @@ export const GetNotesRequestQuery = z.object({
|
|||
sortField: z.string().nullable().optional(),
|
||||
sortOrder: z.string().nullable().optional(),
|
||||
filter: z.string().nullable().optional(),
|
||||
userFilter: z.string().nullable().optional(),
|
||||
});
|
||||
export type GetNotesRequestQueryInput = z.input<typeof GetNotesRequestQuery>;
|
||||
|
||||
|
|
|
@ -51,6 +51,11 @@ paths:
|
|||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
- in: query
|
||||
name: userFilter
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Indicates the requested notes were returned.
|
||||
|
|
|
@ -96,6 +96,11 @@ paths:
|
|||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
- in: query
|
||||
name: userFilter
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
|
|
|
@ -96,6 +96,11 @@ paths:
|
|||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
- in: query
|
||||
name: userFilter
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
|
|
|
@ -549,6 +549,7 @@ export const mockGlobalState: State = {
|
|||
direction: 'desc' as const,
|
||||
},
|
||||
filter: '',
|
||||
userFilter: '',
|
||||
search: '',
|
||||
selectedIds: [],
|
||||
pendingDeleteIds: [],
|
||||
|
|
|
@ -42,6 +42,7 @@ export const fetchNotes = async ({
|
|||
sortField,
|
||||
sortOrder,
|
||||
filter,
|
||||
userFilter,
|
||||
search,
|
||||
}: {
|
||||
page: number;
|
||||
|
@ -49,6 +50,7 @@ export const fetchNotes = async ({
|
|||
sortField: string;
|
||||
sortOrder: string;
|
||||
filter: string;
|
||||
userFilter: string;
|
||||
search: string;
|
||||
}) => {
|
||||
const response = await KibanaServices.get().http.get<GetNotesResponse>(NOTE_URL, {
|
||||
|
@ -58,6 +60,7 @@ export const fetchNotes = async ({
|
|||
sortField,
|
||||
sortOrder,
|
||||
filter,
|
||||
userFilter,
|
||||
search,
|
||||
},
|
||||
version: '2023-10-31',
|
||||
|
|
|
@ -88,7 +88,7 @@ export const AddNote = memo(
|
|||
createNote({
|
||||
note: {
|
||||
timelineId: timelineId || '',
|
||||
eventId,
|
||||
eventId: eventId || '',
|
||||
note: editorValue,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { SearchRow } from './search_row';
|
||||
import { SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids';
|
||||
import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users';
|
||||
|
||||
jest.mock('../../common/components/user_profiles/use_suggest_users');
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
jest.mock('react-redux', () => {
|
||||
const original = jest.requireActual('react-redux');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useDispatch: () => mockDispatch,
|
||||
};
|
||||
});
|
||||
|
||||
describe('SearchRow', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useSuggestUsers as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: [{ user: { username: 'test' } }, { user: { username: 'elastic' } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the component', () => {
|
||||
const { getByTestId } = render(<SearchRow />);
|
||||
|
||||
expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(USER_SELECT_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call the correct action when entering a value in the search bar', async () => {
|
||||
const { getByTestId } = render(<SearchRow />);
|
||||
|
||||
const searchBox = getByTestId(SEARCH_BAR_TEST_ID);
|
||||
|
||||
await userEvent.type(searchBox, 'test');
|
||||
await userEvent.keyboard('{enter}');
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call the correct action when select a user', async () => {
|
||||
const { getByTestId } = render(<SearchRow />);
|
||||
|
||||
const userSelect = getByTestId('comboBoxSearchInput');
|
||||
userSelect.focus();
|
||||
|
||||
const option = await screen.findByText('test');
|
||||
fireEvent.click(option);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -5,25 +5,19 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiSearchBar } from '@elastic/eui';
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiSearchBar } from '@elastic/eui';
|
||||
import React, { useMemo, useCallback, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
import { userSearchedNotes } from '..';
|
||||
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types';
|
||||
import { SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids';
|
||||
import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users';
|
||||
import { userFilterUsers, userSearchedNotes } from '..';
|
||||
|
||||
const SearchRowContainer = styled.div`
|
||||
&:not(:last-child) {
|
||||
margin-bottom: ${(props) => props.theme.eui.euiSizeL};
|
||||
}
|
||||
`;
|
||||
|
||||
SearchRowContainer.displayName = 'SearchRowContainer';
|
||||
|
||||
const SearchRowFlexGroup = styled(EuiFlexGroup)`
|
||||
margin-bottom: ${(props) => props.theme.eui.euiSizeXS};
|
||||
`;
|
||||
|
||||
SearchRowFlexGroup.displayName = 'SearchRowFlexGroup';
|
||||
export const USERS_DROPDOWN = i18n.translate('xpack.securitySolution.notes.usersDropdownLabel', {
|
||||
defaultMessage: 'Users',
|
||||
});
|
||||
|
||||
export const SearchRow = React.memo(() => {
|
||||
const dispatch = useDispatch();
|
||||
|
@ -31,7 +25,7 @@ export const SearchRow = React.memo(() => {
|
|||
() => ({
|
||||
placeholder: 'Search note contents',
|
||||
incremental: false,
|
||||
'data-test-subj': 'notes-search-bar',
|
||||
'data-test-subj': SEARCH_BAR_TEST_ID,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
@ -43,14 +37,43 @@ export const SearchRow = React.memo(() => {
|
|||
[dispatch]
|
||||
);
|
||||
|
||||
const { isLoading: isLoadingSuggestedUsers, data: userProfiles } = useSuggestUsers({
|
||||
searchTerm: '',
|
||||
});
|
||||
const users = useMemo(
|
||||
() =>
|
||||
(userProfiles || []).map((userProfile: UserProfileWithAvatar) => ({
|
||||
label: userProfile.user.full_name || userProfile.user.username,
|
||||
})),
|
||||
[userProfiles]
|
||||
);
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<Array<EuiComboBoxOptionOption<string>>>();
|
||||
const onChange = useCallback(
|
||||
(user: Array<EuiComboBoxOptionOption<string>>) => {
|
||||
setSelectedUser(user);
|
||||
dispatch(userFilterUsers(user.length > 0 ? user[0].label : ''));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchRowContainer>
|
||||
<SearchRowFlexGroup gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiSearchBar box={searchBox} onChange={onQueryChange} defaultQuery="" />
|
||||
</EuiFlexItem>
|
||||
</SearchRowFlexGroup>
|
||||
</SearchRowContainer>
|
||||
<EuiFlexGroup gutterSize="m">
|
||||
<EuiFlexItem>
|
||||
<EuiSearchBar box={searchBox} onChange={onQueryChange} defaultQuery="" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiComboBox
|
||||
prepend={USERS_DROPDOWN}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
options={users}
|
||||
selectedOptions={selectedUser}
|
||||
onChange={onChange}
|
||||
isLoading={isLoadingSuggestedUsers}
|
||||
data-test-subj={USER_SELECT_TEST_ID}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -19,3 +19,5 @@ export const OPEN_FLYOUT_BUTTON_TEST_ID = `${PREFIX}OpenFlyoutButton` as const;
|
|||
export const TIMELINE_DESCRIPTION_COMMENT_TEST_ID = `${PREFIX}TimelineDescriptionComment` as const;
|
||||
export const NOTE_CONTENT_BUTTON_TEST_ID = `${PREFIX}NoteContentButton` as const;
|
||||
export const NOTE_CONTENT_POPOVER_TEST_ID = `${PREFIX}NoteContentPopover` as const;
|
||||
export const SEARCH_BAR_TEST_ID = `${PREFIX}SearchBar` as const;
|
||||
export const USER_SELECT_TEST_ID = `${PREFIX}UserSelect` as const;
|
||||
|
|
|
@ -23,6 +23,7 @@ import {
|
|||
selectNotesTableSelectedIds,
|
||||
selectNotesTableSearch,
|
||||
userSelectedBulkDelete,
|
||||
selectNotesTableUserFilters,
|
||||
} from '..';
|
||||
|
||||
export const BATCH_ACTIONS = i18n.translate(
|
||||
|
@ -51,6 +52,7 @@ export const NotesUtilityBar = React.memo(() => {
|
|||
const pagination = useSelector(selectNotesPagination);
|
||||
const sort = useSelector(selectNotesTableSort);
|
||||
const selectedItems = useSelector(selectNotesTableSelectedIds);
|
||||
const notesUserFilters = useSelector(selectNotesTableUserFilters);
|
||||
const resultsCount = useMemo(() => {
|
||||
const { perPage, page, total } = pagination;
|
||||
const startOfCurrentPage = perPage * (page - 1) + 1;
|
||||
|
@ -83,10 +85,19 @@ export const NotesUtilityBar = React.memo(() => {
|
|||
sortField: sort.field,
|
||||
sortOrder: sort.direction,
|
||||
filter: '',
|
||||
userFilter: notesUserFilters,
|
||||
search: notesSearch,
|
||||
})
|
||||
);
|
||||
}, [dispatch, pagination.page, pagination.perPage, sort.field, sort.direction, notesSearch]);
|
||||
}, [
|
||||
dispatch,
|
||||
pagination.page,
|
||||
pagination.perPage,
|
||||
sort.field,
|
||||
sort.direction,
|
||||
notesUserFilters,
|
||||
notesSearch,
|
||||
]);
|
||||
return (
|
||||
<UtilityBar border>
|
||||
<UtilityBarSection>
|
||||
|
|
|
@ -36,6 +36,7 @@ import {
|
|||
selectNotesTablePendingDeleteIds,
|
||||
selectFetchNotesError,
|
||||
ReqStatus,
|
||||
selectNotesTableUserFilters,
|
||||
} from '..';
|
||||
import type { NotesState } from '..';
|
||||
import { SearchRow } from '../components/search_row';
|
||||
|
@ -119,6 +120,7 @@ export const NoteManagementPage = () => {
|
|||
const pagination = useSelector(selectNotesPagination);
|
||||
const sort = useSelector(selectNotesTableSort);
|
||||
const notesSearch = useSelector(selectNotesTableSearch);
|
||||
const notesUserFilters = useSelector(selectNotesTableUserFilters);
|
||||
const pendingDeleteIds = useSelector(selectNotesTablePendingDeleteIds);
|
||||
const isDeleteModalVisible = pendingDeleteIds.length > 0;
|
||||
const fetchNotesStatus = useSelector(selectFetchNotesStatus);
|
||||
|
@ -134,10 +136,19 @@ export const NoteManagementPage = () => {
|
|||
sortField: sort.field,
|
||||
sortOrder: sort.direction,
|
||||
filter: '',
|
||||
userFilter: notesUserFilters,
|
||||
search: notesSearch,
|
||||
})
|
||||
);
|
||||
}, [dispatch, pagination.page, pagination.perPage, sort.field, sort.direction, notesSearch]);
|
||||
}, [
|
||||
dispatch,
|
||||
pagination.page,
|
||||
pagination.perPage,
|
||||
sort.field,
|
||||
sort.direction,
|
||||
notesUserFilters,
|
||||
notesSearch,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
|
@ -212,6 +223,7 @@ export const NoteManagementPage = () => {
|
|||
<Title title={i18n.NOTES} />
|
||||
<EuiSpacer size="m" />
|
||||
<SearchRow />
|
||||
<EuiSpacer size="m" />
|
||||
<NotesUtilityBar />
|
||||
<EuiBasicTable
|
||||
items={notes}
|
||||
|
|
|
@ -46,6 +46,8 @@ import {
|
|||
fetchNotesBySavedObjectIds,
|
||||
selectNotesBySavedObjectId,
|
||||
selectSortedNotesBySavedObjectId,
|
||||
userFilterUsers,
|
||||
selectNotesTableUserFilters,
|
||||
userClosedCreateErrorToast,
|
||||
} from './notes.slice';
|
||||
import type { NotesState } from './notes.slice';
|
||||
|
@ -69,7 +71,7 @@ const generateNoteMock = (documentId: string): Note => ({
|
|||
const mockNote1 = generateNoteMock('1');
|
||||
const mockNote2 = generateNoteMock('2');
|
||||
|
||||
const initialNonEmptyState = {
|
||||
const initialNonEmptyState: NotesState = {
|
||||
entities: {
|
||||
[mockNote1.noteId]: mockNote1,
|
||||
[mockNote2.noteId]: mockNote2,
|
||||
|
@ -99,6 +101,7 @@ const initialNonEmptyState = {
|
|||
direction: 'desc' as const,
|
||||
},
|
||||
filter: '',
|
||||
userFilter: '',
|
||||
search: '',
|
||||
selectedIds: [],
|
||||
pendingDeleteIds: [],
|
||||
|
@ -501,6 +504,17 @@ describe('notesSlice', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('userFilterUsers', () => {
|
||||
it('should set correct value to filter users', () => {
|
||||
const action = { type: userFilterUsers.type, payload: 'abc' };
|
||||
|
||||
expect(notesReducer(initalEmptyState, action)).toEqual({
|
||||
...initalEmptyState,
|
||||
userFilter: 'abc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('userSearchedNotes', () => {
|
||||
it('should set correct value to search notes', () => {
|
||||
const action = { type: userSearchedNotes.type, payload: 'abc' };
|
||||
|
@ -837,6 +851,14 @@ describe('notesSlice', () => {
|
|||
expect(selectNotesTableSearch(state)).toBe('test search');
|
||||
});
|
||||
|
||||
it('should select associated filter', () => {
|
||||
const state = {
|
||||
...mockGlobalState,
|
||||
notes: { ...initialNotesState, userFilter: 'abc' },
|
||||
};
|
||||
expect(selectNotesTableUserFilters(state)).toBe('abc');
|
||||
});
|
||||
|
||||
it('should select notes table pending delete ids', () => {
|
||||
const state = {
|
||||
...mockGlobalState,
|
||||
|
|
|
@ -57,6 +57,7 @@ export interface NotesState extends EntityState<Note> {
|
|||
direction: 'asc' | 'desc';
|
||||
};
|
||||
filter: string;
|
||||
userFilter: string;
|
||||
search: string;
|
||||
selectedIds: string[];
|
||||
pendingDeleteIds: string[];
|
||||
|
@ -91,6 +92,7 @@ export const initialNotesState: NotesState = notesAdapter.getInitialState({
|
|||
direction: 'desc',
|
||||
},
|
||||
filter: '',
|
||||
userFilter: '',
|
||||
search: '',
|
||||
selectedIds: [],
|
||||
pendingDeleteIds: [],
|
||||
|
@ -124,12 +126,21 @@ export const fetchNotes = createAsyncThunk<
|
|||
sortField: string;
|
||||
sortOrder: string;
|
||||
filter: string;
|
||||
userFilter: string;
|
||||
search: string;
|
||||
},
|
||||
{}
|
||||
>('notes/fetchNotes', async (args) => {
|
||||
const { page, perPage, sortField, sortOrder, filter, search } = args;
|
||||
const res = await fetchNotesApi({ page, perPage, sortField, sortOrder, filter, search });
|
||||
const { page, perPage, sortField, sortOrder, filter, userFilter, search } = args;
|
||||
const res = await fetchNotesApi({
|
||||
page,
|
||||
perPage,
|
||||
sortField,
|
||||
sortOrder,
|
||||
filter,
|
||||
userFilter,
|
||||
search,
|
||||
});
|
||||
return {
|
||||
...normalizeEntities('notes' in res ? res.notes : []),
|
||||
totalCount: 'totalCount' in res ? res.totalCount : 0,
|
||||
|
@ -152,7 +163,7 @@ export const deleteNotes = createAsyncThunk<string[], { ids: string[]; refetch?:
|
|||
await deleteNotesApi(ids);
|
||||
if (refetch) {
|
||||
const state = getState() as State;
|
||||
const { search, pagination, sort } = state.notes;
|
||||
const { search, pagination, userFilter, sort } = state.notes;
|
||||
dispatch(
|
||||
fetchNotes({
|
||||
page: pagination.page,
|
||||
|
@ -160,6 +171,7 @@ export const deleteNotes = createAsyncThunk<string[], { ids: string[]; refetch?:
|
|||
sortField: sort.field,
|
||||
sortOrder: sort.direction,
|
||||
filter: '',
|
||||
userFilter,
|
||||
search,
|
||||
})
|
||||
);
|
||||
|
@ -172,99 +184,102 @@ const notesSlice = createSlice({
|
|||
name: 'notes',
|
||||
initialState: initialNotesState,
|
||||
reducers: {
|
||||
userSelectedPage: (state, action: { payload: number }) => {
|
||||
userSelectedPage: (state: NotesState, action: { payload: number }) => {
|
||||
state.pagination.page = action.payload;
|
||||
},
|
||||
userSelectedPerPage: (state, action: { payload: number }) => {
|
||||
userSelectedPerPage: (state: NotesState, action: { payload: number }) => {
|
||||
state.pagination.perPage = action.payload;
|
||||
},
|
||||
userSortedNotes: (
|
||||
state,
|
||||
state: NotesState,
|
||||
action: { payload: { field: keyof Note; direction: 'asc' | 'desc' } }
|
||||
) => {
|
||||
state.sort = action.payload;
|
||||
},
|
||||
userFilteredNotes: (state, action: { payload: string }) => {
|
||||
userFilteredNotes: (state: NotesState, action: { payload: string }) => {
|
||||
state.filter = action.payload;
|
||||
},
|
||||
userSearchedNotes: (state, action: { payload: string }) => {
|
||||
userFilterUsers: (state: NotesState, action: { payload: string }) => {
|
||||
state.userFilter = action.payload;
|
||||
},
|
||||
userSearchedNotes: (state: NotesState, action: { payload: string }) => {
|
||||
state.search = action.payload;
|
||||
},
|
||||
userSelectedRow: (state, action: { payload: string[] }) => {
|
||||
userSelectedRow: (state: NotesState, action: { payload: string[] }) => {
|
||||
state.selectedIds = action.payload;
|
||||
},
|
||||
userClosedDeleteModal: (state) => {
|
||||
userClosedDeleteModal: (state: NotesState) => {
|
||||
state.pendingDeleteIds = [];
|
||||
},
|
||||
userSelectedNotesForDeletion: (state, action: { payload: string }) => {
|
||||
userSelectedNotesForDeletion: (state: NotesState, action: { payload: string }) => {
|
||||
state.pendingDeleteIds = [action.payload];
|
||||
},
|
||||
userSelectedBulkDelete: (state) => {
|
||||
userSelectedBulkDelete: (state: NotesState) => {
|
||||
state.pendingDeleteIds = state.selectedIds;
|
||||
},
|
||||
userClosedCreateErrorToast: (state) => {
|
||||
userClosedCreateErrorToast: (state: NotesState) => {
|
||||
state.error.createNote = null;
|
||||
},
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder
|
||||
.addCase(fetchNotesByDocumentIds.pending, (state) => {
|
||||
.addCase(fetchNotesByDocumentIds.pending, (state: NotesState) => {
|
||||
state.status.fetchNotesByDocumentIds = ReqStatus.Loading;
|
||||
})
|
||||
.addCase(fetchNotesByDocumentIds.fulfilled, (state, action) => {
|
||||
.addCase(fetchNotesByDocumentIds.fulfilled, (state: NotesState, action) => {
|
||||
notesAdapter.upsertMany(state, action.payload.entities.notes);
|
||||
state.status.fetchNotesByDocumentIds = ReqStatus.Succeeded;
|
||||
})
|
||||
.addCase(fetchNotesByDocumentIds.rejected, (state, action) => {
|
||||
.addCase(fetchNotesByDocumentIds.rejected, (state: NotesState, action) => {
|
||||
state.status.fetchNotesByDocumentIds = ReqStatus.Failed;
|
||||
state.error.fetchNotesByDocumentIds = action.payload ?? action.error;
|
||||
})
|
||||
.addCase(fetchNotesBySavedObjectIds.pending, (state) => {
|
||||
.addCase(fetchNotesBySavedObjectIds.pending, (state: NotesState) => {
|
||||
state.status.fetchNotesBySavedObjectIds = ReqStatus.Loading;
|
||||
})
|
||||
.addCase(fetchNotesBySavedObjectIds.fulfilled, (state, action) => {
|
||||
.addCase(fetchNotesBySavedObjectIds.fulfilled, (state: NotesState, action) => {
|
||||
notesAdapter.upsertMany(state, action.payload.entities.notes);
|
||||
state.status.fetchNotesBySavedObjectIds = ReqStatus.Succeeded;
|
||||
})
|
||||
.addCase(fetchNotesBySavedObjectIds.rejected, (state, action) => {
|
||||
.addCase(fetchNotesBySavedObjectIds.rejected, (state: NotesState, action) => {
|
||||
state.status.fetchNotesBySavedObjectIds = ReqStatus.Failed;
|
||||
state.error.fetchNotesBySavedObjectIds = action.payload ?? action.error;
|
||||
})
|
||||
.addCase(createNote.pending, (state) => {
|
||||
.addCase(createNote.pending, (state: NotesState) => {
|
||||
state.status.createNote = ReqStatus.Loading;
|
||||
})
|
||||
.addCase(createNote.fulfilled, (state, action) => {
|
||||
.addCase(createNote.fulfilled, (state: NotesState, action) => {
|
||||
notesAdapter.addMany(state, action.payload.entities.notes);
|
||||
state.status.createNote = ReqStatus.Succeeded;
|
||||
})
|
||||
.addCase(createNote.rejected, (state, action) => {
|
||||
.addCase(createNote.rejected, (state: NotesState, action) => {
|
||||
state.status.createNote = ReqStatus.Failed;
|
||||
state.error.createNote = action.payload ?? action.error;
|
||||
})
|
||||
.addCase(deleteNotes.pending, (state) => {
|
||||
.addCase(deleteNotes.pending, (state: NotesState) => {
|
||||
state.status.deleteNotes = ReqStatus.Loading;
|
||||
})
|
||||
.addCase(deleteNotes.fulfilled, (state, action) => {
|
||||
.addCase(deleteNotes.fulfilled, (state: NotesState, action) => {
|
||||
notesAdapter.removeMany(state, action.payload);
|
||||
state.status.deleteNotes = ReqStatus.Succeeded;
|
||||
state.pendingDeleteIds = state.pendingDeleteIds.filter(
|
||||
(value) => !action.payload.includes(value)
|
||||
);
|
||||
})
|
||||
.addCase(deleteNotes.rejected, (state, action) => {
|
||||
.addCase(deleteNotes.rejected, (state: NotesState, action) => {
|
||||
state.status.deleteNotes = ReqStatus.Failed;
|
||||
state.error.deleteNotes = action.payload ?? action.error;
|
||||
})
|
||||
.addCase(fetchNotes.pending, (state) => {
|
||||
.addCase(fetchNotes.pending, (state: NotesState) => {
|
||||
state.status.fetchNotes = ReqStatus.Loading;
|
||||
})
|
||||
.addCase(fetchNotes.fulfilled, (state, action) => {
|
||||
.addCase(fetchNotes.fulfilled, (state: NotesState, action) => {
|
||||
notesAdapter.setAll(state, action.payload.entities.notes);
|
||||
state.pagination.total = action.payload.totalCount;
|
||||
state.status.fetchNotes = ReqStatus.Succeeded;
|
||||
state.selectedIds = [];
|
||||
})
|
||||
.addCase(fetchNotes.rejected, (state, action) => {
|
||||
.addCase(fetchNotes.rejected, (state: NotesState, action) => {
|
||||
state.status.fetchNotes = ReqStatus.Failed;
|
||||
state.error.fetchNotes = action.payload ?? action.error;
|
||||
});
|
||||
|
@ -307,6 +322,8 @@ export const selectNotesTableSelectedIds = (state: State) => state.notes.selecte
|
|||
|
||||
export const selectNotesTableSearch = (state: State) => state.notes.search;
|
||||
|
||||
export const selectNotesTableUserFilters = (state: State) => state.notes.userFilter;
|
||||
|
||||
export const selectNotesTablePendingDeleteIds = (state: State) => state.notes.pendingDeleteIds;
|
||||
|
||||
export const selectFetchNotesError = (state: State) => state.notes.error.fetchNotes;
|
||||
|
@ -394,6 +411,7 @@ export const {
|
|||
userSelectedPerPage,
|
||||
userSortedNotes,
|
||||
userFilteredNotes,
|
||||
userFilterUsers,
|
||||
userSearchedNotes,
|
||||
userSelectedRow,
|
||||
userClosedDeleteModal,
|
||||
|
|
|
@ -11,6 +11,7 @@ import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'
|
|||
import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
|
||||
import type { SavedObjectsFindOptions } from '@kbn/core-saved-objects-api-server';
|
||||
import { nodeBuilder } from '@kbn/es-query';
|
||||
import type { KueryNode } from '@kbn/es-query';
|
||||
import { timelineSavedObjectType } from '../../saved_object_mappings';
|
||||
import type { SecuritySolutionPluginRouter } from '../../../../types';
|
||||
import { MAX_UNASSOCIATED_NOTES, NOTE_URL } from '../../../../../common/constants';
|
||||
|
@ -126,6 +127,22 @@ export const getNotesRoute = (router: SecuritySolutionPluginRouter) => {
|
|||
sortOrder,
|
||||
filter,
|
||||
};
|
||||
|
||||
// retrieve all the notes created by a specific user
|
||||
const userFilter = queryParams?.userFilter;
|
||||
if (userFilter) {
|
||||
// we need to combine the associatedFilter with the filter query
|
||||
// we have to type case here because the filter is a string (from the schema) and that cannot be changed as it would be a breaking change
|
||||
const filterAsKueryNode: KueryNode = (filter || '') as unknown as KueryNode;
|
||||
|
||||
options.filter = nodeBuilder.and([
|
||||
nodeBuilder.is(`${noteSavedObjectType}.attributes.createdBy`, userFilter),
|
||||
filterAsKueryNode,
|
||||
]);
|
||||
} else {
|
||||
options.filter = filter;
|
||||
}
|
||||
|
||||
const res = await getAllSavedNote(frameworkRequest, options);
|
||||
const body: GetNotesResponse = res ?? {};
|
||||
return response.ok({ body });
|
||||
|
|
|
@ -7,7 +7,10 @@
|
|||
|
||||
import type SuperTest from 'supertest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { BareNote, TimelineTypeEnum } from '@kbn/security-solution-plugin/common/api/timeline';
|
||||
import {
|
||||
PersistNoteRouteRequestBody,
|
||||
TimelineTypeEnum,
|
||||
} from '@kbn/security-solution-plugin/common/api/timeline';
|
||||
import { NOTE_URL } from '@kbn/security-solution-plugin/common/constants';
|
||||
import type { Client } from '@elastic/elasticsearch';
|
||||
import { SECURITY_SOLUTION_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
|
||||
|
@ -58,7 +61,6 @@ export const createNote = async (
|
|||
note: {
|
||||
documentId?: string;
|
||||
savedObjectId?: string;
|
||||
user?: string;
|
||||
text: string;
|
||||
}
|
||||
) =>
|
||||
|
@ -70,9 +72,9 @@ export const createNote = async (
|
|||
eventId: note.documentId || '',
|
||||
timelineId: note.savedObjectId || '',
|
||||
created: Date.now(),
|
||||
createdBy: note.user || 'elastic',
|
||||
createdBy: 'elastic',
|
||||
updated: Date.now(),
|
||||
updatedBy: note.user || 'elastic',
|
||||
updatedBy: 'elastic',
|
||||
note: note.text,
|
||||
} as BareNote,
|
||||
});
|
||||
},
|
||||
} as PersistNoteRouteRequestBody);
|
||||
|
|
|
@ -408,8 +408,41 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
});
|
||||
|
||||
// TODO should add more tests for the filter query parameter (I don't know how it's supposed to work)
|
||||
|
||||
// TODO should add more tests for the MAX_UNASSOCIATED_NOTES advanced settings values
|
||||
|
||||
// TODO figure out why this test is failing on CI but not locally
|
||||
// we can't really test for other users because the persistNote endpoint forces overrideOwner to be true then all the notes created here are owned by the elastic user
|
||||
it.skip('should retrieve all notes that have been created by a specific user', async () => {
|
||||
await Promise.all([
|
||||
createNote(supertest, { text: 'first note' }),
|
||||
createNote(supertest, { text: 'second note' }),
|
||||
]);
|
||||
|
||||
const response = await supertest
|
||||
.get('/api/note?userFilter=elastic')
|
||||
.set('kbn-xsrf', 'true')
|
||||
.set('elastic-api-version', '2023-10-31');
|
||||
|
||||
const { totalCount } = response.body;
|
||||
|
||||
expect(totalCount).to.be(2);
|
||||
});
|
||||
|
||||
it('should return nothing if no notes have been created by that user', async () => {
|
||||
await Promise.all([
|
||||
createNote(supertest, { text: 'first note' }),
|
||||
createNote(supertest, { text: 'second note' }),
|
||||
]);
|
||||
|
||||
const response = await supertest
|
||||
.get('/api/note?userFilter=user1')
|
||||
.set('kbn-xsrf', 'true')
|
||||
.set('elastic-api-version', '2023-10-31');
|
||||
|
||||
const { totalCount } = response.body;
|
||||
|
||||
expect(totalCount).to.be(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue