From dcd8e0c614183ae648e00979eb82123656076d16 Mon Sep 17 00:00:00 2001 From: Philippe Oberti Date: Tue, 22 Oct 2024 08:32:48 -0500 Subject: [PATCH] [Security Solution][Notes] - fix user filter not checking correct license in notes management page (#197149) --- .../upselling/messages/index.tsx | 8 ++ .../upselling/service/types.ts | 3 +- .../user_profiles/use_suggest_users.tsx | 21 +++-- .../notes/components/search_row.test.tsx | 33 ++++---- .../public/notes/components/search_row.tsx | 44 ++--------- .../components/user_filter_dropdown.test.tsx | 69 ++++++++++++++++ .../notes/components/user_filter_dropdown.tsx | 79 +++++++++++++++++++ .../public/upselling/register_upsellings.tsx | 6 ++ 8 files changed, 202 insertions(+), 61 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/notes/components/user_filter_dropdown.test.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/user_filter_dropdown.tsx diff --git a/x-pack/packages/security-solution/upselling/messages/index.tsx b/x-pack/packages/security-solution/upselling/messages/index.tsx index 722a711995d01..4bda9477f13c0 100644 --- a/x-pack/packages/security-solution/upselling/messages/index.tsx +++ b/x-pack/packages/security-solution/upselling/messages/index.tsx @@ -46,3 +46,11 @@ export const ALERT_SUPPRESSION_RULE_DETAILS = i18n.translate( 'Alert suppression is configured but will not be applied due to insufficient licensing', } ); + +export const UPGRADE_NOTES_MANAGEMENT_USER_FILTER = (requiredLicense: string) => + i18n.translate('securitySolutionPackages.noteManagement.userFilter.upsell', { + defaultMessage: 'Upgrade to {requiredLicense} to make use of user filters', + values: { + requiredLicense, + }, + }); diff --git a/x-pack/packages/security-solution/upselling/service/types.ts b/x-pack/packages/security-solution/upselling/service/types.ts index 43019271a7e02..b053c9aedf857 100644 --- a/x-pack/packages/security-solution/upselling/service/types.ts +++ b/x-pack/packages/security-solution/upselling/service/types.ts @@ -27,4 +27,5 @@ export type UpsellingMessageId = | 'investigation_guide_interactions' | 'alert_assignments' | 'alert_suppression_rule_form' - | 'alert_suppression_rule_details'; + | 'alert_suppression_rule_details' + | 'note_management_user_filter'; diff --git a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.tsx b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.tsx index a8a2338e51e9d..626d621f61a30 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.tsx +++ b/x-pack/plugins/security_solution/public/common/components/user_profiles/use_suggest_users.tsx @@ -13,10 +13,6 @@ import { suggestUsers } from './api'; import { USER_PROFILES_FAILURE } from './translations'; import { useAppToasts } from '../../hooks/use_app_toasts'; -export interface SuggestUserProfilesArgs { - searchTerm: string; -} - export const bulkGetUserProfiles = async ({ searchTerm, }: { @@ -25,7 +21,21 @@ export const bulkGetUserProfiles = async ({ return suggestUsers({ searchTerm }); }; -export const useSuggestUsers = ({ searchTerm }: { searchTerm: string }) => { +export interface UseSuggestUsersParams { + /** + * Search term to filter user profiles + */ + searchTerm: string; + /** + * Whether the query should be enabled + */ + enabled?: boolean; +} + +/** + * Fetches user profiles based on a search term + */ +export const useSuggestUsers = ({ enabled = true, searchTerm }: UseSuggestUsersParams) => { const { addError } = useAppToasts(); return useQuery( @@ -36,6 +46,7 @@ export const useSuggestUsers = ({ searchTerm }: { searchTerm: string }) => { { retry: false, staleTime: Infinity, + enabled, onError: (e) => { addError(e, { title: USER_PROFILES_FAILURE }); }, diff --git a/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx b/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx index be9546c77525b..447ade158306b 100644 --- a/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx @@ -5,13 +5,14 @@ * 2.0. */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { SearchRow } from './search_row'; import { ASSOCIATED_NOT_SELECT_TEST_ID, SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids'; import { AssociatedFilter } from '../../../common/notes/constants'; import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users'; +import { TestProviders } from '../../common/mock'; jest.mock('../../common/components/user_profiles/use_suggest_users'); @@ -35,7 +36,11 @@ describe('SearchRow', () => { }); it('should render the component', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + + + ); expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument(); expect(getByTestId(USER_SELECT_TEST_ID)).toBeInTheDocument(); @@ -43,7 +48,11 @@ describe('SearchRow', () => { }); it('should call the correct action when entering a value in the search bar', async () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + + + ); const searchBox = getByTestId(SEARCH_BAR_TEST_ID); @@ -53,20 +62,12 @@ describe('SearchRow', () => { expect(mockDispatch).toHaveBeenCalled(); }); - it('should call the correct action when select a user', async () => { - const { getByTestId } = render(); - - const userSelect = getByTestId('comboBoxSearchInput'); - userSelect.focus(); - - const option = await screen.findByText('test'); - fireEvent.click(option); - - expect(mockDispatch).toHaveBeenCalled(); - }); - it('should call the correct action when select a value in the associated note dropdown', async () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + + + ); const associatedNoteSelect = getByTestId(ASSOCIATED_NOT_SELECT_TEST_ID); await userEvent.selectOptions(associatedNoteSelect, [AssociatedFilter.documentOnly]); diff --git a/x-pack/plugins/security_solution/public/notes/components/search_row.tsx b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx index d540a586814d8..f2f90b3ba7e0d 100644 --- a/x-pack/plugins/security_solution/public/notes/components/search_row.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx @@ -5,10 +5,9 @@ * 2.0. */ -import React, { useMemo, useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; import type { EuiSelectOption } from '@elastic/eui'; import { - EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiSearchBar, @@ -16,17 +15,12 @@ import { useGeneratedHtmlId, } from '@elastic/eui'; import { useDispatch } from 'react-redux'; -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 { ASSOCIATED_NOT_SELECT_TEST_ID, SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids'; -import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users'; -import { userFilterAssociatedNotes, userFilterUsers, userSearchedNotes } from '..'; +import { UserFilterDropdown } from './user_filter_dropdown'; +import { ASSOCIATED_NOT_SELECT_TEST_ID, SEARCH_BAR_TEST_ID } from './test_ids'; +import { userFilterAssociatedNotes, userSearchedNotes } from '..'; import { AssociatedFilter } from '../../../common/notes/constants'; -export const USERS_DROPDOWN = i18n.translate('xpack.securitySolution.notes.usersDropdownLabel', { - defaultMessage: 'Users', -}); const FILTER_SELECT = i18n.translate('xpack.securitySolution.notes.management.filterSelect', { defaultMessage: 'Select filter', }); @@ -55,26 +49,6 @@ 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>>(); - const onChange = useCallback( - (user: Array>) => { - setSelectedUser(user); - dispatch(userFilterUsers(user.length > 0 ? user[0].label : '')); - }, - [dispatch] - ); - const onAssociatedNoteSelectChange = useCallback( (e: React.ChangeEvent) => { dispatch(userFilterAssociatedNotes(e.target.value as AssociatedFilter)); @@ -88,15 +62,7 @@ export const SearchRow = React.memo(() => { - + { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +describe('UserFilterDropdown', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useSuggestUsers as jest.Mock).mockReturnValue({ + isLoading: false, + data: [{ user: { username: 'test' } }, { user: { username: 'elastic' } }], + }); + (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true }); + (useUpsellingMessage as jest.Mock).mockReturnValue('upsellingMessage'); + }); + + it('should render the component enabled', () => { + const { getByTestId } = render(); + + const dropdown = getByTestId(USER_SELECT_TEST_ID); + + expect(dropdown).toBeInTheDocument(); + expect(dropdown).not.toHaveClass('euiComboBox-isDisabled'); + }); + + it('should render the dropdown disabled', async () => { + (useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => false }); + + const { getByTestId } = render(); + + expect(getByTestId(USER_SELECT_TEST_ID)).toHaveClass('euiComboBox-isDisabled'); + }); + + it('should call the correct action when select a user', async () => { + const { getByTestId } = render(); + + const userSelect = getByTestId('comboBoxSearchInput'); + userSelect.focus(); + + const option = await screen.findByText('test'); + fireEvent.click(option); + + expect(mockDispatch).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/notes/components/user_filter_dropdown.tsx b/x-pack/plugins/security_solution/public/notes/components/user_filter_dropdown.tsx new file mode 100644 index 0000000000000..78f4ef6dd2ac8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/user_filter_dropdown.tsx @@ -0,0 +1,79 @@ +/* + * 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 React, { useMemo, useCallback, useState } from 'react'; +import { EuiComboBox, EuiToolTip } from '@elastic/eui'; +import { useDispatch } from 'react-redux'; +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 { useLicense } from '../../common/hooks/use_license'; +import { useUpsellingMessage } from '../../common/hooks/use_upselling'; +import { USER_SELECT_TEST_ID } from './test_ids'; +import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users'; +import { userFilterUsers } from '..'; + +export const USERS_DROPDOWN = i18n.translate('xpack.securitySolution.notes.usersDropdownLabel', { + defaultMessage: 'Users', +}); + +export const UserFilterDropdown = React.memo(() => { + const dispatch = useDispatch(); + const isPlatinumPlus = useLicense().isPlatinumPlus(); + const upsellingMessage = useUpsellingMessage('note_management_user_filter'); + + const { isLoading, data } = useSuggestUsers({ + searchTerm: '', + enabled: isPlatinumPlus, + }); + const users = useMemo( + () => + (data || []).map((userProfile: UserProfileWithAvatar) => ({ + label: userProfile.user.full_name || userProfile.user.username, + })), + [data] + ); + + const [selectedUser, setSelectedUser] = useState>>(); + const onChange = useCallback( + (user: Array>) => { + setSelectedUser(user); + dispatch(userFilterUsers(user.length > 0 ? user[0].label : '')); + }, + [dispatch] + ); + + const dropdown = useMemo( + () => ( + + ), + [isLoading, isPlatinumPlus, onChange, selectedUser, users] + ); + + return ( + <> + {isPlatinumPlus ? ( + <>{dropdown} + ) : ( + + {dropdown} + + )} + + ); +}); + +UserFilterDropdown.displayName = 'UserFilterDropdown'; diff --git a/x-pack/plugins/security_solution_ess/public/upselling/register_upsellings.tsx b/x-pack/plugins/security_solution_ess/public/upselling/register_upsellings.tsx index b7fbdab3b5982..69f3c5dd4cc28 100644 --- a/x-pack/plugins/security_solution_ess/public/upselling/register_upsellings.tsx +++ b/x-pack/plugins/security_solution_ess/public/upselling/register_upsellings.tsx @@ -12,6 +12,7 @@ import { ALERT_SUPPRESSION_RULE_FORM, UPGRADE_ALERT_ASSIGNMENTS, UPGRADE_INVESTIGATION_GUIDE, + UPGRADE_NOTES_MANAGEMENT_USER_FILTER, } from '@kbn/security-solution-upselling/messages'; import type { MessageUpsellings, @@ -132,4 +133,9 @@ export const upsellingMessages: UpsellingMessages = [ minimumLicenseRequired: 'platinum', message: ALERT_SUPPRESSION_RULE_DETAILS, }, + { + id: 'note_management_user_filter', + minimumLicenseRequired: 'platinum', + message: UPGRADE_NOTES_MANAGEMENT_USER_FILTER('Platinum'), + }, ];