diff --git a/packages/kbn-user-profile-components/src/user_profiles_popover.test.tsx b/packages/kbn-user-profile-components/src/user_profiles_popover.test.tsx index 6f838631591ce..a68f09cba6a54 100644 --- a/packages/kbn-user-profile-components/src/user_profiles_popover.test.tsx +++ b/packages/kbn-user-profile-components/src/user_profiles_popover.test.tsx @@ -8,10 +8,11 @@ import { shallow } from 'enzyme'; import React from 'react'; +import { UserProfile } from './user_profile'; import { UserProfilesPopover } from './user_profiles_popover'; -const userProfiles = [ +const userProfiles: UserProfile[] = [ { uid: 'u_BOulL4QMPSyV9jg5lQI2JmCkUnokHTazBnet3xVHNv0_0', enabled: true, diff --git a/packages/kbn-user-profile-components/src/user_profiles_selectable.test.tsx b/packages/kbn-user-profile-components/src/user_profiles_selectable.test.tsx index ad3b3c94ac6da..05eb4966496a0 100644 --- a/packages/kbn-user-profile-components/src/user_profiles_selectable.test.tsx +++ b/packages/kbn-user-profile-components/src/user_profiles_selectable.test.tsx @@ -8,10 +8,11 @@ import { mount } from 'enzyme'; import React from 'react'; +import { UserProfile } from './user_profile'; import { UserProfilesSelectable } from './user_profiles_selectable'; -const userProfiles = [ +const userProfiles: UserProfile[] = [ { uid: 'u_BOulL4QMPSyV9jg5lQI2JmCkUnokHTazBnet3xVHNv0_0', enabled: true, diff --git a/x-pack/plugins/cases/common/api/cases/suggest_user_profiles.ts b/x-pack/plugins/cases/common/api/cases/suggest_user_profiles.ts deleted file mode 100644 index 75cd1f9cb9f94..0000000000000 --- a/x-pack/plugins/cases/common/api/cases/suggest_user_profiles.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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 * as rt from 'io-ts'; - -export const SuggestUserProfilesRequestRt = rt.intersection([ - rt.type({ - name: rt.string, - owners: rt.array(rt.string), - }), - rt.partial({ size: rt.number }), -]); - -export type SuggestUserProfilesRequest = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/user.ts b/x-pack/plugins/cases/common/api/user.ts index 2696ad60a4568..63280d230b777 100644 --- a/x-pack/plugins/cases/common/api/user.ts +++ b/x-pack/plugins/cases/common/api/user.ts @@ -7,11 +7,14 @@ import * as rt from 'io-ts'; -export const UserRT = rt.type({ - email: rt.union([rt.undefined, rt.null, rt.string]), - full_name: rt.union([rt.undefined, rt.null, rt.string]), - username: rt.union([rt.undefined, rt.null, rt.string]), -}); +export const UserRT = rt.intersection([ + rt.type({ + email: rt.union([rt.undefined, rt.null, rt.string]), + full_name: rt.union([rt.undefined, rt.null, rt.string]), + username: rt.union([rt.undefined, rt.null, rt.string]), + }), + rt.partial({ profile_uid: rt.string }), +]); export const UsersRt = rt.array(UserRT); diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index 9e85d6e4cbf7a..ccc4ada609d03 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -163,3 +163,8 @@ export const PUSH_CASES_CAPABILITY = 'push_cases' as const; */ export const DEFAULT_USER_SIZE = 10; + +/** + * Delays + */ +export const SEARCH_DEBOUNCE_MS = 500; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 9fef8ae47b3b0..2ae40c2e33961 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -103,6 +103,7 @@ export interface FilterOptions { severity: CaseSeverityWithAll; status: CaseStatusWithAllStatus; tags: string[]; + assignees: string[]; reporters: User[]; owner: string[]; } @@ -162,7 +163,7 @@ export interface FieldMappings { export type UpdateKey = keyof Pick< CasePatchRequest, - 'connector' | 'description' | 'status' | 'tags' | 'title' | 'settings' | 'severity' + 'connector' | 'description' | 'status' | 'tags' | 'title' | 'settings' | 'severity' | 'assignees' >; export interface UpdateByKey { diff --git a/x-pack/plugins/cases/public/common/mock/index.ts b/x-pack/plugins/cases/public/common/mock/index.ts index add4c1c206dd4..c607eb2985af8 100644 --- a/x-pack/plugins/cases/public/common/mock/index.ts +++ b/x-pack/plugins/cases/public/common/mock/index.ts @@ -6,3 +6,4 @@ */ export * from './test_providers'; +export * from './permissions'; diff --git a/x-pack/plugins/cases/public/common/mock/permissions.ts b/x-pack/plugins/cases/public/common/mock/permissions.ts new file mode 100644 index 0000000000000..1166dbed8ca88 --- /dev/null +++ b/x-pack/plugins/cases/public/common/mock/permissions.ts @@ -0,0 +1,69 @@ +/* + * 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 { CasesCapabilities, CasesPermissions } from '../../containers/types'; + +export const allCasesPermissions = () => buildCasesPermissions(); +export const noCasesPermissions = () => + buildCasesPermissions({ read: false, create: false, update: false, delete: false, push: false }); +export const readCasesPermissions = () => + buildCasesPermissions({ read: true, create: false, update: false, delete: false, push: false }); +export const noCreateCasesPermissions = () => buildCasesPermissions({ create: false }); +export const noUpdateCasesPermissions = () => buildCasesPermissions({ update: false }); +export const noPushCasesPermissions = () => buildCasesPermissions({ push: false }); +export const noDeleteCasesPermissions = () => buildCasesPermissions({ delete: false }); +export const writeCasesPermissions = () => buildCasesPermissions({ read: false }); + +export const buildCasesPermissions = (overrides: Partial> = {}) => { + const create = overrides.create ?? true; + const read = overrides.read ?? true; + const update = overrides.update ?? true; + const deletePermissions = overrides.delete ?? true; + const push = overrides.push ?? true; + const all = create && read && update && deletePermissions && push; + + return { + all, + create, + read, + update, + delete: deletePermissions, + push, + }; +}; + +export const allCasesCapabilities = () => buildCasesCapabilities(); +export const noCasesCapabilities = () => + buildCasesCapabilities({ + create_cases: false, + read_cases: false, + update_cases: false, + delete_cases: false, + push_cases: false, + }); +export const readCasesCapabilities = () => + buildCasesCapabilities({ + create_cases: false, + update_cases: false, + delete_cases: false, + push_cases: false, + }); +export const writeCasesCapabilities = () => { + return buildCasesCapabilities({ + read_cases: false, + }); +}; + +export const buildCasesCapabilities = (overrides?: Partial) => { + return { + create_cases: overrides?.create_cases ?? true, + read_cases: overrides?.read_cases ?? true, + update_cases: overrides?.update_cases ?? true, + delete_cases: overrides?.delete_cases ?? true, + push_cases: overrides?.push_cases ?? true, + }; +}; diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index dceb8fd0f30a7..a180fb942bd15 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -5,6 +5,8 @@ * 2.0. */ +/* eslint-disable no-console */ + import React from 'react'; import { euiDarkVars } from '@kbn/ui-theme'; import { I18nProvider } from '@kbn/i18n-react'; @@ -14,7 +16,7 @@ import { render as reactRender, RenderOptions, RenderResult } from '@testing-lib import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; -import { CasesCapabilities, CasesFeatures, CasesPermissions } from '../../../common/ui/types'; +import { CasesFeatures, CasesPermissions } from '../../../common/ui/types'; import { CasesProvider } from '../../components/cases_context'; import { createKibanaContextProviderMock, @@ -25,6 +27,7 @@ import { StartServices } from '../../types'; import { ReleasePhase } from '../../components/types'; import { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry'; import { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_framework/persistable_state_registry'; +import { allCasesPermissions } from './permissions'; interface TestProviderProps { children: React.ReactNode; @@ -56,6 +59,11 @@ const TestProvidersComponent: React.FC = ({ retry: false, }, }, + logger: { + log: console.log, + warn: console.warn, + error: () => {}, + }, }); return ( @@ -98,69 +106,17 @@ export const testQueryClient = new QueryClient({ retry: false, }, }, + /** + * React query prints the errors in the console even though + * all tests are passings. We turn them off for testing. + */ + logger: { + log: console.log, + warn: console.warn, + error: () => {}, + }, }); -export const allCasesPermissions = () => buildCasesPermissions(); -export const noCasesPermissions = () => - buildCasesPermissions({ read: false, create: false, update: false, delete: false, push: false }); -export const readCasesPermissions = () => - buildCasesPermissions({ read: true, create: false, update: false, delete: false, push: false }); -export const noCreateCasesPermissions = () => buildCasesPermissions({ create: false }); -export const noUpdateCasesPermissions = () => buildCasesPermissions({ update: false }); -export const noPushCasesPermissions = () => buildCasesPermissions({ push: false }); -export const noDeleteCasesPermissions = () => buildCasesPermissions({ delete: false }); -export const writeCasesPermissions = () => buildCasesPermissions({ read: false }); - -export const buildCasesPermissions = (overrides: Partial> = {}) => { - const create = overrides.create ?? true; - const read = overrides.read ?? true; - const update = overrides.update ?? true; - const deletePermissions = overrides.delete ?? true; - const push = overrides.push ?? true; - const all = create && read && update && deletePermissions && push; - - return { - all, - create, - read, - update, - delete: deletePermissions, - push, - }; -}; - -export const allCasesCapabilities = () => buildCasesCapabilities(); -export const noCasesCapabilities = () => - buildCasesCapabilities({ - create_cases: false, - read_cases: false, - update_cases: false, - delete_cases: false, - push_cases: false, - }); -export const readCasesCapabilities = () => - buildCasesCapabilities({ - create_cases: false, - update_cases: false, - delete_cases: false, - push_cases: false, - }); -export const writeCasesCapabilities = () => { - return buildCasesCapabilities({ - read_cases: false, - }); -}; - -export const buildCasesCapabilities = (overrides?: Partial) => { - return { - create_cases: overrides?.create_cases ?? true, - read_cases: overrides?.read_cases ?? true, - update_cases: overrides?.update_cases ?? true, - delete_cases: overrides?.delete_cases ?? true, - push_cases: overrides?.push_cases ?? true, - }; -}; - export const createAppMockRenderer = ({ features, owner = [SECURITY_SOLUTION_OWNER], @@ -176,6 +132,11 @@ export const createAppMockRenderer = ({ retry: false, }, }, + logger: { + log: console.log, + warn: console.warn, + error: () => {}, + }, }); const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => ( diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index d8aedbc10bd3d..c38408c8f5417 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -37,12 +37,14 @@ import { registerConnectorsToMockActionRegistry } from '../../common/mock/regist import { createStartServicesMock } from '../../common/lib/kibana/kibana_react.mock'; import { waitForComponentToUpdate } from '../../common/test_utils'; import { useCreateAttachments } from '../../containers/use_create_attachments'; -import { useGetReporters } from '../../containers/use_get_reporters'; import { useGetCasesMetrics } from '../../containers/use_get_cases_metrics'; import { useGetConnectors } from '../../containers/configure/use_connectors'; import { useGetTags } from '../../containers/use_get_tags'; import { useUpdateCase } from '../../containers/use_update_case'; import { useGetCases } from '../../containers/use_get_cases'; +import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; +import { userProfiles, userProfilesMap } from '../../containers/user_profiles/api.mock'; +import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; jest.mock('../../containers/use_create_attachments'); jest.mock('../../containers/use_bulk_update_case'); @@ -52,7 +54,8 @@ jest.mock('../../containers/use_get_cases_status'); jest.mock('../../containers/use_get_cases_metrics'); jest.mock('../../containers/use_get_action_license'); jest.mock('../../containers/use_get_tags'); -jest.mock('../../containers/use_get_reporters'); +jest.mock('../../containers/user_profiles/use_get_current_user_profile'); +jest.mock('../../containers/user_profiles/use_bulk_get_user_profiles'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../common/lib/kibana'); jest.mock('../../common/navigation/hooks'); @@ -67,7 +70,8 @@ const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; const useGetCasesMetricsMock = useGetCasesMetrics as jest.Mock; const useUpdateCasesMock = useUpdateCases as jest.Mock; const useGetTagsMock = useGetTags as jest.Mock; -const useGetReportersMock = useGetReporters as jest.Mock; +const useGetCurrentUserProfileMock = useGetCurrentUserProfile as jest.Mock; +const useBulkGetUserProfilesMock = useBulkGetUserProfiles as jest.Mock; const useKibanaMock = useKibana as jest.MockedFunction; const useGetConnectorsMock = useGetConnectors as jest.Mock; const useCreateAttachmentsMock = useCreateAttachments as jest.Mock; @@ -145,6 +149,8 @@ describe('AllCasesListGeneric', () => { handleIsLoading: jest.fn(), isLoadingCases: [], isSelectorView: false, + userProfiles: new Map(), + currentUserProfile: undefined, }; let appMockRenderer: AppMockRenderer; @@ -164,13 +170,8 @@ describe('AllCasesListGeneric', () => { useGetCasesStatusMock.mockReturnValue(defaultCasesStatus); useGetCasesMetricsMock.mockReturnValue(defaultCasesMetrics); useGetTagsMock.mockReturnValue({ data: ['coke', 'pepsi'], refetch: jest.fn() }); - useGetReportersMock.mockReturnValue({ - reporters: ['casetester'], - respReporters: [{ username: 'casetester' }], - isLoading: true, - isError: false, - fetchReporters: jest.fn(), - }); + useGetCurrentUserProfileMock.mockReturnValue({ data: userProfiles[0], isLoading: false }); + useBulkGetUserProfilesMock.mockReturnValue({ data: userProfilesMap }); useGetConnectorsMock.mockImplementation(() => ({ data: connectorsMock, isLoading: false })); useUpdateCaseMock.mockReturnValue({ updateCaseProperty }); mockKibana(); @@ -194,9 +195,9 @@ describe('AllCasesListGeneric', () => { expect( wrapper.find(`span[data-test-subj="case-table-column-tags-coke"]`).first().prop('title') ).toEqual(useGetCasesMockState.data.cases[0].tags[0]); - expect(wrapper.find(`[data-test-subj="case-table-column-createdBy"]`).first().text()).toEqual( - 'LK' - ); + expect( + wrapper.find(`[data-test-subj="case-user-profile-avatar-damaged_raccoon"]`).first().text() + ).toEqual('DR'); expect( wrapper .find(`[data-test-subj="case-table-column-createdAt"]`) @@ -215,20 +216,17 @@ describe('AllCasesListGeneric', () => { }); }); - it('should show a tooltip with the reporter username when hover over the reporter avatar', async () => { + it("should show a tooltip with the assignee's email when hover over the assignee avatar", async () => { const result = render( ); - userEvent.hover(result.queryAllByTestId('case-table-column-createdBy')[0]); + userEvent.hover(result.queryAllByTestId('case-user-profile-avatar-damaged_raccoon')[0]); await waitFor(() => { - expect(result.getByTestId('case-table-column-createdBy-tooltip')).toBeTruthy(); - expect(result.getByTestId('case-table-column-createdBy-tooltip').textContent).toEqual( - 'lknope' - ); + expect(result.getByText('damaged_raccoon@elastic.co')).toBeInTheDocument(); }); }); @@ -263,6 +261,7 @@ describe('AllCasesListGeneric', () => { title: null, totalComment: null, totalAlerts: null, + assignees: [], }, ], }, @@ -588,7 +587,7 @@ describe('AllCasesListGeneric', () => { wrapper.find('[data-test-subj="cases-table-row-select-1"]').first().simulate('click'); await waitFor(() => { expect(onRowClick).toHaveBeenCalledWith({ - assignees: [], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], closedAt: null, closedBy: null, comments: [], diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index 3c056ccf996dd..ce38c82f08384 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -10,6 +10,7 @@ import { EuiProgress, EuiBasicTable, EuiTableSelectionType } from '@elastic/eui' import { difference, head, isEmpty } from 'lodash/fp'; import styled, { css } from 'styled-components'; +import { useQueryClient } from '@tanstack/react-query'; import { Case, CaseStatusWithAllStatus, @@ -35,6 +36,12 @@ import { initialData, useGetCases, } from '../../containers/use_get_cases'; +import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; +import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; +import { + USER_PROFILES_BULK_GET_CACHE_KEY, + USER_PROFILES_CACHE_KEY, +} from '../../containers/constants'; const ProgressLoader = styled(EuiProgress)` ${({ $isShow }: { $isShow: boolean }) => @@ -78,6 +85,7 @@ export const AllCasesList = React.memo( }); const [queryParams, setQueryParams] = useState(DEFAULT_QUERY_PARAMS); const [selectedCases, setSelectedCases] = useState([]); + const queryClient = useQueryClient(); const { data = initialData, @@ -88,6 +96,26 @@ export const AllCasesList = React.memo( queryParams, }); + const assigneesFromCases = useMemo(() => { + return data.cases.reduce>((acc, caseInfo) => { + if (!caseInfo) { + return acc; + } + + for (const assignee of caseInfo.assignees) { + acc.add(assignee.uid); + } + return acc; + }, new Set()); + }, [data.cases]); + + const { data: userProfiles } = useBulkGetUserProfiles({ + uids: Array.from(assigneesFromCases), + }); + + const { data: currentUserProfile, isLoading: isLoadingCurrentUserProfile } = + useGetCurrentUserProfile(); + const { data: connectors = [] } = useGetConnectors(); const sorting = useMemo( @@ -118,6 +146,8 @@ export const AllCasesList = React.memo( deselectCases(); if (dataRefresh) { refetchCases(); + queryClient.refetchQueries([USER_PROFILES_CACHE_KEY, USER_PROFILES_BULK_GET_CACHE_KEY]); + setRefresh((currRefresh: number) => currRefresh + 1); } if (doRefresh) { @@ -127,7 +157,7 @@ export const AllCasesList = React.memo( filterRefetch.current(); } }, - [deselectCases, doRefresh, refetchCases] + [deselectCases, doRefresh, queryClient, refetchCases] ); const tableOnChangeCallback = useCallback( @@ -193,6 +223,8 @@ export const AllCasesList = React.memo( const columns = useCasesColumns({ filterStatus: filterOptions.status ?? StatusAll, + userProfiles: userProfiles ?? new Map(), + currentUserProfile, handleIsLoading, refreshCases, isSelectorView, @@ -245,6 +277,7 @@ export const AllCasesList = React.memo( initial={{ search: filterOptions.search, searchFields: filterOptions.searchFields, + assignees: filterOptions.assignees, reporters: filterOptions.reporters, tags: filterOptions.tags, status: filterOptions.status, @@ -255,6 +288,8 @@ export const AllCasesList = React.memo( hiddenStatuses={hiddenStatuses} displayCreateCaseButton={isSelectorView} onCreateCasePressed={onRowClick} + isLoading={isLoadingCurrentUserProfile} + currentUserProfile={currentUserProfile} /> { + let appMockRender: AppMockRenderer; + let defaultProps: AssigneesFilterPopoverProps; + + beforeEach(() => { + jest.clearAllMocks(); + + appMockRender = createAppMockRenderer(); + + defaultProps = { + currentUserProfile: undefined, + selectedAssignees: [], + isLoading: false, + onSelectionChange: jest.fn(), + }; + }); + + it('calls onSelectionChange when 1 user is selected', async () => { + const onSelectionChange = jest.fn(); + const props = { ...defaultProps, onSelectionChange }; + appMockRender.render(); + + await waitFor(() => { + userEvent.click(screen.getByTestId('options-filter-popover-button-assignees')); + expect(screen.getByPlaceholderText('Search users')).toBeInTheDocument(); + }); + await waitForEuiPopoverOpen(); + + fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'dingo' } }); + userEvent.click(screen.getByText('wet_dingo@elastic.co')); + + expect(onSelectionChange.mock.calls[0][0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object {}, + "enabled": true, + "uid": "u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0", + "user": Object { + "email": "wet_dingo@elastic.co", + "full_name": "Wet Dingo", + "username": "wet_dingo", + }, + }, + ] + `); + }); + + it('calls onSelectionChange with a single user when different users are selected', async () => { + const onSelectionChange = jest.fn(); + const props = { ...defaultProps, onSelectionChange }; + appMockRender.render(); + + await waitFor(() => { + userEvent.click(screen.getByTestId('options-filter-popover-button-assignees')); + expect(screen.getByText('wet_dingo@elastic.co')); + }); + + await waitForEuiPopoverOpen(); + + fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'dingo' } }); + userEvent.click(screen.getByText('wet_dingo@elastic.co')); + userEvent.click(screen.getByText('damaged_raccoon@elastic.co')); + + expect(onSelectionChange.mock.calls[0][0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object {}, + "enabled": true, + "uid": "u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0", + "user": Object { + "email": "wet_dingo@elastic.co", + "full_name": "Wet Dingo", + "username": "wet_dingo", + }, + }, + ] + `); + expect(onSelectionChange.mock.calls[1][0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object {}, + "enabled": true, + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "user": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "username": "damaged_raccoon", + }, + }, + ] + `); + }); + + it('does not show the assigned users total if there are no assigned users', async () => { + appMockRender.render(); + + await waitFor(() => { + userEvent.click(screen.getByTestId('options-filter-popover-button-assignees')); + expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); + }); + + await waitForEuiPopoverOpen(); + + expect(screen.queryByText('assignee')).not.toBeInTheDocument(); + }); + + it('shows the 1 assigned total when the users are passed in', async () => { + const props = { + ...defaultProps, + selectedAssignees: [userProfiles[0]], + }; + appMockRender.render(); + + await waitFor(async () => { + userEvent.click(screen.getByTestId('options-filter-popover-button-assignees')); + expect(screen.getByText('1 assignee filtered')).toBeInTheDocument(); + }); + + await waitForEuiPopoverOpen(); + + expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); + }); + + it('shows three users when initially rendered', async () => { + appMockRender.render(); + + await waitFor(() => { + userEvent.click(screen.getByTestId('options-filter-popover-button-assignees')); + expect(screen.getByText('Wet Dingo')).toBeInTheDocument(); + }); + await waitForEuiPopoverOpen(); + + expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); + expect(screen.getByText('Physical Dinosaur')).toBeInTheDocument(); + }); + + it('shows the users sorted alphabetically with the current user at the front', async () => { + const props = { + ...defaultProps, + currentUserProfile: userProfiles[2], + }; + + appMockRender.render(); + + await waitFor(() => { + userEvent.click(screen.getByTestId('options-filter-popover-button-assignees')); + expect(screen.getByText('Wet Dingo')).toBeInTheDocument(); + }); + await waitForEuiPopoverOpen(); + + const assignees = screen.getAllByRole('option'); + expect(within(assignees[0]).getByText('Wet Dingo')).toBeInTheDocument(); + expect(within(assignees[1]).getByText('Damaged Raccoon')).toBeInTheDocument(); + expect(within(assignees[2]).getByText('Physical Dinosaur')).toBeInTheDocument(); + }); + + it('does not show the number of filters', async () => { + appMockRender.render(); + + await waitFor(() => { + userEvent.click(screen.getByTestId('options-filter-popover-button-assignees')); + expect(screen.getByText('Wet Dingo')).toBeInTheDocument(); + }); + await waitForEuiPopoverOpen(); + + expect(screen.queryByText('3')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/assignees_filter.tsx b/x-pack/plugins/cases/public/components/all_cases/assignees_filter.tsx new file mode 100644 index 0000000000000..6ffa18cfa8073 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/assignees_filter.tsx @@ -0,0 +1,126 @@ +/* + * 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 { EuiFilterButton } from '@elastic/eui'; +import { UserProfilesPopover, UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { isEmpty } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useSuggestUserProfiles } from '../../containers/user_profiles/use_suggest_user_profiles'; +import { useAvailableCasesOwners } from '../app/use_available_owners'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { CurrentUserProfile } from '../types'; +import { EmptyMessage } from '../user_profiles/empty_message'; +import { NoMatches } from '../user_profiles/no_matches'; +import { SelectedStatusMessage } from '../user_profiles/selected_status_message'; +import { bringCurrentUserToFrontAndSort } from '../user_profiles/sort'; +import * as i18n from './translations'; + +export interface AssigneesFilterPopoverProps { + selectedAssignees: UserProfileWithAvatar[]; + currentUserProfile: CurrentUserProfile; + isLoading: boolean; + onSelectionChange: (users: UserProfileWithAvatar[]) => void; +} + +const AssigneesFilterPopoverComponent: React.FC = ({ + selectedAssignees, + currentUserProfile, + isLoading, + onSelectionChange, +}) => { + const { owner: owners } = useCasesContext(); + const hasOwners = owners.length > 0; + const availableOwners = useAvailableCasesOwners(['read']); + const [searchTerm, setSearchTerm] = useState(''); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const togglePopover = useCallback(() => setIsPopoverOpen((value) => !value), []); + + const onChange = useCallback( + (users: UserProfileWithAvatar[]) => { + const sortedUsers = bringCurrentUserToFrontAndSort(currentUserProfile, users); + onSelectionChange(sortedUsers ?? []); + }, + [currentUserProfile, onSelectionChange] + ); + + const selectedStatusMessage = useCallback( + (selectedCount: number) => ( + + ), + [] + ); + + const onSearchChange = useCallback((term: string) => { + setSearchTerm(term); + + if (!isEmpty(term)) { + setIsUserTyping(true); + } + }, []); + + const [isUserTyping, setIsUserTyping] = useState(false); + + const onDebounce = useCallback(() => setIsUserTyping(false), []); + + const { data: userProfiles, isLoading: isLoadingSuggest } = useSuggestUserProfiles({ + name: searchTerm, + owners: hasOwners ? owners : availableOwners, + onDebounce, + }); + + const searchResultProfiles = useMemo( + () => bringCurrentUserToFrontAndSort(currentUserProfile, userProfiles), + [userProfiles, currentUserProfile] + ); + + const isLoadingData = isLoading || isLoadingSuggest; + + return ( + 0} + numActiveFilters={selectedAssignees.length} + aria-label={i18n.FILTER_ASSIGNEES_ARIA_LABEL} + > + {i18n.ASSIGNEES} + + } + selectableProps={{ + onChange, + onSearchChange, + selectedStatusMessage, + options: searchResultProfiles, + selectedOptions: selectedAssignees, + isLoading: isLoadingData || isUserTyping, + height: 'full', + searchPlaceholder: i18n.SEARCH_USERS, + clearButtonLabel: i18n.CLEAR_FILTERS, + emptyMessage: , + noMatchesMessage: !isUserTyping && !isLoadingData ? : , + singleSelection: false, + }} + /> + ); +}; +AssigneesFilterPopoverComponent.displayName = 'AssigneesFilterPopover'; + +export const AssigneesFilterPopover = React.memo(AssigneesFilterPopoverComponent); diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index 0929f8971cf06..be5b8ace2e2b6 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -7,7 +7,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { - EuiAvatar, EuiBadgeGroup, EuiBadge, EuiButton, @@ -24,6 +23,7 @@ import { import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import styled from 'styled-components'; +import { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { Case, DeleteCase, UpdateByKey } from '../../../common/ui/types'; import { CaseStatuses, ActionConnector, CaseSeverity } from '../../../common/api'; import { OWNER_INFO } from '../../../common/constants'; @@ -44,6 +44,11 @@ import { useCasesFeatures } from '../cases_context/use_cases_features'; import { severities } from '../severity/config'; import { useUpdateCase } from '../../containers/use_update_case'; import { useCasesContext } from '../cases_context/use_cases_context'; +import { UserToolTip } from '../user_profiles/user_tooltip'; +import { CaseUserAvatar } from '../user_profiles/user_avatar'; +import { useAssignees } from '../../containers/user_profiles/use_assignees'; +import { getUsernameDataTestSubj } from '../user_profiles/data_test_subject'; +import { CurrentUserProfile } from '../types'; export type CasesColumns = | EuiTableActionsColumnType @@ -57,8 +62,45 @@ const MediumShadeText = styled.p` const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); +const AssigneesColumn: React.FC<{ + assignees: Case['assignees']; + userProfiles: Map; + currentUserProfile: CurrentUserProfile; +}> = ({ assignees, userProfiles, currentUserProfile }) => { + const { allAssignees } = useAssignees({ + caseAssignees: assignees, + userProfiles, + currentUserProfile, + }); + + if (allAssignees.length <= 0) { + return getEmptyTagValue(); + } + + return ( + + {allAssignees.map((assignee) => { + const dataTestSubjName = getUsernameDataTestSubj(assignee); + return ( + + + + + + ); + })} + + ); +}; +AssigneesColumn.displayName = 'AssigneesColumn'; export interface GetCasesColumn { filterStatus: string; + userProfiles: Map; + currentUserProfile: CurrentUserProfile; handleIsLoading: (a: boolean) => void; refreshCases?: (a?: boolean) => void; isSelectorView: boolean; @@ -69,6 +111,8 @@ export interface GetCasesColumn { } export const useCasesColumns = ({ filterStatus, + userProfiles, + currentUserProfile, handleIsLoading, refreshCases, isSelectorView, @@ -173,27 +217,15 @@ export const useCasesColumns = ({ }, }, { - field: 'createdBy', - name: i18n.REPORTER, - render: (createdBy: Case['createdBy']) => { - if (createdBy != null) { - return ( - - - - ); - } - return getEmptyTagValue(); - }, + field: 'assignees', + name: i18n.ASSIGNEES, + render: (assignees: Case['assignees']) => ( + + ), }, { field: 'tags', diff --git a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx index 8e5263a31ff3d..38f0b9d53b1d1 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx @@ -16,15 +16,16 @@ import { noCreateCasesPermissions, TestProviders, } from '../../common/mock'; -import { useGetReporters } from '../../containers/use_get_reporters'; import { useGetActionLicense } from '../../containers/use_get_action_license'; import { casesStatus, connectorsMock, useGetCasesMockState } from '../../containers/mock'; import { useGetCasesStatus } from '../../containers/use_get_cases_status'; import { useGetConnectors } from '../../containers/configure/use_connectors'; import { useGetTags } from '../../containers/use_get_tags'; import { useGetCases } from '../../containers/use_get_cases'; +import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; +import { userProfiles, userProfilesMap } from '../../containers/user_profiles/api.mock'; +import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; -jest.mock('../../containers/use_get_reporters'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/use_get_action_license', () => { return { @@ -35,11 +36,15 @@ jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/api'); jest.mock('../../containers/use_get_cases'); jest.mock('../../containers/use_get_cases_status'); +jest.mock('../../containers/user_profiles/use_get_current_user_profile'); +jest.mock('../../containers/user_profiles/use_bulk_get_user_profiles'); const useGetConnectorsMock = useGetConnectors as jest.Mock; const useGetCasesMock = useGetCases as jest.Mock; const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; const useGetActionLicenseMock = useGetActionLicense as jest.Mock; +const useGetCurrentUserProfileMock = useGetCurrentUserProfile as jest.Mock; +const useBulkGetUserProfilesMock = useBulkGetUserProfiles as jest.Mock; describe('AllCases', () => { const refetchCases = jest.fn(); @@ -71,17 +76,13 @@ describe('AllCases', () => { beforeAll(() => { (useGetTags as jest.Mock).mockReturnValue({ data: ['coke', 'pepsi'], refetch: jest.fn() }); - (useGetReporters as jest.Mock).mockReturnValue({ - reporters: ['casetester'], - respReporters: [{ username: 'casetester' }], - isLoading: true, - isError: false, - fetchReporters: jest.fn(), - }); useGetConnectorsMock.mockImplementation(() => ({ data: connectorsMock, isLoading: false })); useGetCasesStatusMock.mockReturnValue(defaultCasesStatus); useGetActionLicenseMock.mockReturnValue(defaultActionLicense); useGetCasesMock.mockReturnValue(defaultGetCases); + + useGetCurrentUserProfileMock.mockReturnValue({ data: userProfiles[0], isLoading: false }); + useBulkGetUserProfilesMock.mockReturnValue({ data: userProfilesMap }); }); let appMockRender: AppMockRenderer; diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx index 4c477e1b4a581..23a8772852e17 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx @@ -7,22 +7,23 @@ import React from 'react'; import { mount } from 'enzyme'; +import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import { CaseStatuses } from '../../../common/api'; import { OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; -import { useGetReporters } from '../../containers/use_get_reporters'; import { DEFAULT_FILTER_OPTIONS } from '../../containers/use_get_cases'; import { CasesTableFilters } from './table_filters'; import { useGetTags } from '../../containers/use_get_tags'; +import { useSuggestUserProfiles } from '../../containers/user_profiles/use_suggest_user_profiles'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; -jest.mock('../../containers/use_get_reporters'); jest.mock('../../containers/use_get_tags'); +jest.mock('../../containers/user_profiles/use_suggest_user_profiles'); const onFilterChanged = jest.fn(); -const fetchReporters = jest.fn(); const refetch = jest.fn(); const setFilterRefetch = jest.fn(); @@ -34,6 +35,8 @@ const props = { initial: DEFAULT_FILTER_OPTIONS, setFilterRefetch, availableSolutions: [], + isLoading: false, + currentUserProfile: undefined, }; describe('CasesTableFilters ', () => { @@ -42,13 +45,7 @@ describe('CasesTableFilters ', () => { appMockRender = createAppMockRenderer(); jest.clearAllMocks(); (useGetTags as jest.Mock).mockReturnValue({ data: ['coke', 'pepsi'], refetch }); - (useGetReporters as jest.Mock).mockReturnValue({ - reporters: ['casetester'], - respReporters: [{ username: 'casetester' }], - isLoading: true, - isError: false, - fetchReporters, - }); + (useSuggestUserProfiles as jest.Mock).mockReturnValue({ data: userProfiles, isLoading: false }); }); it('should render the case status filter dropdown', () => { @@ -87,23 +84,20 @@ describe('CasesTableFilters ', () => { expect(onFilterChanged).toBeCalledWith({ tags: ['coke'] }); }); - it('should call onFilterChange when selected reporters change', () => { - const wrapper = mount( - - - - ); - wrapper - .find(`[data-test-subj="options-filter-popover-button-Reporter"]`) - .last() - .simulate('click'); + it('should call onFilterChange when selected assignees change', async () => { + const { getByTestId, getByText } = appMockRender.render(); + userEvent.click(getByTestId('options-filter-popover-button-assignees')); + await waitForEuiPopoverOpen(); - wrapper - .find(`[data-test-subj="options-filter-popover-item-casetester"]`) - .last() - .simulate('click'); + userEvent.click(getByText('Physical Dinosaur')); - expect(onFilterChanged).toBeCalledWith({ reporters: [{ username: 'casetester' }] }); + expect(onFilterChanged.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "assignees": Array [ + "u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0", + ], + } + `); }); it('should call onFilterChange when search changes', () => { @@ -157,23 +151,32 @@ describe('CasesTableFilters ', () => { expect(onFilterChanged).toHaveBeenCalledWith({ tags: ['pepsi'] }); }); - it('should remove reporter from selected reporters when reporter no longer exists', () => { - const ourProps = { + it('should remove assignee from selected assignees when assignee no longer exists', async () => { + const overrideProps = { ...props, initial: { ...DEFAULT_FILTER_OPTIONS, - reporters: [ - { username: 'casetester', full_name: null, email: null }, - { username: 'batman', full_name: null, email: null }, + assignees: [ + // invalid profile uid + '123', + 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0', ], }, }; - mount( - - - - ); - expect(onFilterChanged).toHaveBeenCalledWith({ reporters: [{ username: 'casetester' }] }); + + appMockRender.render(); + userEvent.click(screen.getByTestId('options-filter-popover-button-assignees')); + await waitForEuiPopoverOpen(); + + userEvent.click(screen.getByText('Physical Dinosaur')); + + expect(onFilterChanged.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "assignees": Array [ + "u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0", + ], + } + `); }); it('StatusFilterWrapper should have a fixed width of 180px', () => { diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx index cedd7c9b64718..25da22f9be168 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx @@ -10,10 +10,10 @@ import { isEqual } from 'lodash/fp'; import styled from 'styled-components'; import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup, EuiButton } from '@elastic/eui'; +import { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { StatusAll, CaseStatusWithAllStatus, CaseSeverityWithAll } from '../../../common/ui/types'; import { CaseStatuses } from '../../../common/api'; import { FilterOptions } from '../../containers/types'; -import { useGetReporters } from '../../containers/use_get_reporters'; import { FilterPopover } from '../filter_popover'; import { StatusFilter } from './status_filter'; import * as i18n from './translations'; @@ -21,6 +21,8 @@ import { SeverityFilter } from './severity_filter'; import { useGetTags } from '../../containers/use_get_tags'; import { CASE_LIST_CACHE_KEY } from '../../containers/constants'; import { DEFAULT_FILTER_OPTIONS } from '../../containers/use_get_cases'; +import { AssigneesFilterPopover } from './assignees_filter'; +import { CurrentUserProfile } from '../types'; interface CasesTableFiltersProps { countClosedCases: number | null; @@ -33,6 +35,8 @@ interface CasesTableFiltersProps { availableSolutions: string[]; displayCreateCaseButton?: boolean; onCreateCasePressed?: () => void; + isLoading: boolean; + currentUserProfile: CurrentUserProfile; } // Fix the width of the status dropdown to prevent hiding long text items @@ -59,20 +63,18 @@ const CasesTableFiltersComponent = ({ availableSolutions, displayCreateCaseButton, onCreateCasePressed, + isLoading, + currentUserProfile, }: CasesTableFiltersProps) => { - const [selectedReporters, setSelectedReporters] = useState( - initial.reporters.map((r) => r.full_name ?? r.username ?? '') - ); const [search, setSearch] = useState(initial.search); const [selectedTags, setSelectedTags] = useState(initial.tags); const [selectedOwner, setSelectedOwner] = useState(initial.owner); + const [selectedAssignees, setSelectedAssignees] = useState([]); const { data: tags = [], refetch: fetchTags } = useGetTags(CASE_LIST_CACHE_KEY); - const { reporters, respReporters, fetchReporters } = useGetReporters(); const refetch = useCallback(() => { fetchTags(); - fetchReporters(); - }, [fetchReporters, fetchTags]); + }, [fetchTags]); useEffect(() => { if (setFilterRefetch != null) { @@ -80,26 +82,16 @@ const CasesTableFiltersComponent = ({ } }, [refetch, setFilterRefetch]); - const handleSelectedReporters = useCallback( - (newReporters) => { - if (!isEqual(newReporters, selectedReporters)) { - setSelectedReporters(newReporters); - const reportersObj = respReporters.filter( - (r) => newReporters.includes(r.username) || newReporters.includes(r.full_name) - ); - onFilterChanged({ reporters: reportersObj }); + const handleSelectedAssignees = useCallback( + (newAssignees: UserProfileWithAvatar[]) => { + if (!isEqual(newAssignees, selectedAssignees)) { + setSelectedAssignees(newAssignees); + onFilterChanged({ assignees: newAssignees.map((assignee) => assignee.uid) }); } }, - [selectedReporters, respReporters, onFilterChanged] + [selectedAssignees, onFilterChanged] ); - useEffect(() => { - if (selectedReporters.length) { - const newReporters = selectedReporters.filter((r) => reporters.includes(r)); - handleSelectedReporters(newReporters); - } - }, [handleSelectedReporters, reporters, selectedReporters]); - const handleSelectedTags = useCallback( (newTags) => { if (!isEqual(newTags, selectedTags)) { @@ -202,12 +194,11 @@ const CasesTableFiltersComponent = ({ - + i18n.translate('xpack.cases.allCasesView.totalFilteredUsers', { + defaultMessage: '{total, plural, one {# assignee} other {# assignees}} filtered', + values: { total }, + }); diff --git a/x-pack/plugins/cases/public/components/app/use_available_owners.ts b/x-pack/plugins/cases/public/components/app/use_available_owners.ts index 8715af5d6fa68..9e593fa6c282a 100644 --- a/x-pack/plugins/cases/public/components/app/use_available_owners.ts +++ b/x-pack/plugins/cases/public/components/app/use_available_owners.ts @@ -8,8 +8,9 @@ import { APP_ID, FEATURE_ID } from '../../../common/constants'; import { useKibana } from '../../common/lib/kibana'; import { CasesPermissions } from '../../containers/types'; +import { allCasePermissions } from '../../utils/permissions'; -type Capability = Omit; +type Capability = Exclude; /** * @@ -18,7 +19,7 @@ type Capability = Omit; **/ export const useAvailableCasesOwners = ( - capabilities: Capability[] = ['create', 'read', 'update', 'delete', 'push'] + capabilities: Capability[] = allCasePermissions ): string[] => { const { capabilities: kibanaCapabilities } = useKibana().services.application; diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index 284785b33d720..3855f14134f46 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -21,6 +21,7 @@ import { useGetCaseUserActions } from '../../containers/use_get_case_user_action import { useGetTags } from '../../containers/use_get_tags'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { useUpdateCase } from '../../containers/use_update_case'; +import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; import { CaseViewPage } from './case_view_page'; import { caseData, @@ -40,6 +41,7 @@ jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/use_get_case'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/use_post_push_to_service'); +jest.mock('../../containers/user_profiles/use_bulk_get_user_profiles'); jest.mock('../user_actions/timestamp', () => ({ UserActionTimestamp: () => <>, })); @@ -56,6 +58,7 @@ const useGetConnectorsMock = useGetConnectors as jest.Mock; const usePostPushToServiceMock = usePostPushToService as jest.Mock; const useGetCaseMetricsMock = useGetCaseMetrics as jest.Mock; const useGetTagsMock = useGetTags as jest.Mock; +const useBulkGetUserProfilesMock = useBulkGetUserProfiles as jest.Mock; const mockGetCase = (props: Partial = {}) => { const data = { @@ -96,6 +99,7 @@ describe('CaseViewPage', () => { usePostPushToServiceMock.mockReturnValue({ isLoading: false, pushCaseToExternalService }); useGetConnectorsMock.mockReturnValue({ data: connectorsMock, isLoading: false }); useGetTagsMock.mockReturnValue({ data: [], isLoading: false }); + useBulkGetUserProfilesMock.mockReturnValue({ data: new Map(), isLoading: false }); appMockRenderer = createAppMockRenderer(); }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx new file mode 100644 index 0000000000000..fdd4b5cb77e7d --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx @@ -0,0 +1,402 @@ +/* + * 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 { useSuggestUserProfiles } from '../../../containers/user_profiles/use_suggest_user_profiles'; +import { useGetCurrentUserProfile } from '../../../containers/user_profiles/use_get_current_user_profile'; +import { userProfiles, userProfilesMap } from '../../../containers/user_profiles/api.mock'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { + AppMockRenderer, + createAppMockRenderer, + noUpdateCasesPermissions, +} from '../../../common/mock'; +import { AssignUsers, AssignUsersProps } from './assign_users'; +import { waitForEuiPopoverClose, waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; + +jest.mock('../../../containers/user_profiles/use_suggest_user_profiles'); +jest.mock('../../../containers/user_profiles/use_get_current_user_profile'); + +const useSuggestUserProfilesMock = useSuggestUserProfiles as jest.Mock; +const useGetCurrentUserProfileMock = useGetCurrentUserProfile as jest.Mock; + +const currentUserProfile = userProfiles[0]; + +describe('AssignUsers', () => { + let appMockRender: AppMockRenderer; + let defaultProps: AssignUsersProps; + + beforeEach(() => { + defaultProps = { + caseAssignees: [], + currentUserProfile, + userProfiles: new Map(), + onAssigneesChanged: jest.fn(), + isLoading: false, + }; + + useSuggestUserProfilesMock.mockReturnValue({ data: userProfiles, isLoading: false }); + useGetCurrentUserProfileMock.mockReturnValue({ data: currentUserProfile, isLoading: false }); + + appMockRender = createAppMockRenderer(); + }); + + it('does not show any assignees when there are none assigned', () => { + appMockRender.render(); + + expect(screen.getByText('No users have been assigned.')).toBeInTheDocument(); + }); + + it('does not show the suggest users edit button when the user does not have update permissions', () => { + appMockRender = createAppMockRenderer({ permissions: noUpdateCasesPermissions() }); + appMockRender.render(); + + expect(screen.queryByText('case-view-assignees-edit')).not.toBeInTheDocument(); + }); + + it('does not show the assign users link when the user does not have update permissions', () => { + appMockRender = createAppMockRenderer({ permissions: noUpdateCasesPermissions() }); + appMockRender.render(); + + expect(screen.queryByTestId('assign yourself')).not.toBeInTheDocument(); + expect(screen.queryByTestId('Assign a user')).not.toBeInTheDocument(); + }); + + it('does not show the suggest users edit button when the component is still loading', () => { + appMockRender.render(); + + expect(screen.queryByTestId('case-view-assignees-edit')).not.toBeInTheDocument(); + expect(screen.getByTestId('case-view-assignees-button-loading')).toBeInTheDocument(); + }); + + it('does not show the assign yourself link when the current profile is undefined', () => { + appMockRender.render(); + + expect(screen.queryByText('assign yourself')).not.toBeInTheDocument(); + expect(screen.getByText('Assign a user')).toBeInTheDocument(); + }); + + it('shows the suggest users edit button when the user has update permissions', () => { + appMockRender.render(); + + expect(screen.getByTestId('case-view-assignees-edit')).toBeInTheDocument(); + }); + + it('shows the two initially assigned users', () => { + const props = { + ...defaultProps, + caseAssignees: userProfiles.slice(0, 2), + userProfiles: userProfilesMap, + }; + appMockRender.render(); + + expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); + expect(screen.getByText('Physical Dinosaur')).toBeInTheDocument(); + expect(screen.queryByText('Wet Dingo')).not.toBeInTheDocument(); + expect(screen.queryByText('No users have been assigned.')).not.toBeInTheDocument(); + expect(screen.queryByTestId('case-view-assignees-loading')).not.toBeInTheDocument(); + }); + + it('shows the rerendered assignees', () => { + const { rerender } = appMockRender.render(); + + const props = { + ...defaultProps, + caseAssignees: userProfiles.slice(0, 2), + userProfiles: userProfilesMap, + }; + rerender(); + + expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); + expect(screen.getByText('Physical Dinosaur')).toBeInTheDocument(); + expect(screen.queryByText('Wet Dingo')).not.toBeInTheDocument(); + expect(screen.queryByText('No users have been assigned.')).not.toBeInTheDocument(); + expect(screen.queryByTestId('case-view-assignees-loading')).not.toBeInTheDocument(); + }); + + it('shows the popover when the pencil is clicked', async () => { + const props = { + ...defaultProps, + userProfiles: userProfilesMap, + }; + appMockRender.render(); + + fireEvent.click(screen.getByTestId('case-view-assignees-edit-button')); + await waitForEuiPopoverOpen(); + fireEvent.change(screen.getByPlaceholderText('Search users'), { + target: { value: 'damaged_raccoon@elastic.co' }, + }); + + expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); + }); + + it('shows the popover when the assign a user link is clicked', async () => { + const props = { + ...defaultProps, + userProfiles: userProfilesMap, + }; + appMockRender.render(); + + fireEvent.click(screen.getByText('Assign a user')); + await waitForEuiPopoverOpen(); + fireEvent.change(screen.getByPlaceholderText('Search users'), { + target: { value: 'damaged_raccoon@elastic.co' }, + }); + + expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); + }); + + it('assigns the current user when the assign yourself link is clicked', async () => { + const onAssigneesChanged = jest.fn(); + const props = { + ...defaultProps, + onAssigneesChanged, + userProfiles: userProfilesMap, + }; + appMockRender.render(); + + fireEvent.click(screen.getByText('assign yourself')); + + await waitFor(() => expect(onAssigneesChanged).toBeCalledTimes(1)); + + expect(onAssigneesChanged.mock.calls[0][0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object {}, + "enabled": true, + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "user": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "username": "damaged_raccoon", + }, + }, + ] + `); + }); + + it('calls onAssigneesChanged with an empty array because all the users were deleted', async () => { + const onAssigneesChanged = jest.fn(); + const props = { + ...defaultProps, + caseAssignees: [{ uid: userProfiles[0].uid }], + onAssigneesChanged, + userProfiles: userProfilesMap, + }; + appMockRender.render(); + + fireEvent.mouseEnter( + screen.getByTestId(`user-profile-assigned-user-group-${userProfiles[0].user.username}`) + ); + fireEvent.click( + screen.getByTestId(`user-profile-assigned-user-cross-${userProfiles[0].user.username}`) + ); + + await waitFor(() => expect(onAssigneesChanged).toBeCalledTimes(1)); + + expect(onAssigneesChanged.mock.calls[0][0]).toMatchInlineSnapshot(`Array []`); + }); + + it('calls onAssigneesChanged when the popover is closed using the pencil button', async () => { + const onAssigneesChanged = jest.fn(); + const props = { + ...defaultProps, + onAssigneesChanged, + userProfiles: userProfilesMap, + }; + appMockRender.render(); + + fireEvent.click(screen.getByTestId('case-view-assignees-edit-button')); + await waitForEuiPopoverOpen(); + fireEvent.change(screen.getByPlaceholderText('Search users'), { + target: { value: 'damaged_raccoon@elastic.co' }, + }); + + fireEvent.click(screen.getByText('Damaged Raccoon')); + + // close the popover + fireEvent.click(screen.getByTestId('case-view-assignees-edit-button')); + await waitForEuiPopoverClose(); + + await waitFor(() => expect(onAssigneesChanged).toBeCalledTimes(1)); + + expect(onAssigneesChanged.mock.calls[0][0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object {}, + "enabled": true, + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "user": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "username": "damaged_raccoon", + }, + }, + ] + `); + }); + + it('does not call onAssigneesChanged when the selected assignees have not changed between renders', async () => { + const onAssigneesChanged = jest.fn(); + const props = { + ...defaultProps, + caseAssignees: [{ uid: userProfiles[0].uid }], + onAssigneesChanged, + userProfiles: userProfilesMap, + }; + appMockRender.render(); + + fireEvent.click(screen.getByTestId('case-view-assignees-edit-button')); + await waitForEuiPopoverOpen(); + + // close the popover + fireEvent.click(screen.getByTestId('case-view-assignees-edit-button')); + await waitForEuiPopoverClose(); + + await waitFor(() => expect(onAssigneesChanged).toBeCalledTimes(0)); + }); + + it('calls onAssigneesChanged without unknownId1', async () => { + const onAssigneesChanged = jest.fn(); + const props = { + ...defaultProps, + caseAssignees: [{ uid: 'unknownId1' }, { uid: 'unknownId2' }], + onAssigneesChanged, + userProfiles: userProfilesMap, + }; + appMockRender.render(); + + fireEvent.mouseEnter(screen.getByTestId(`user-profile-assigned-user-group-unknownId1`)); + fireEvent.click(screen.getByTestId(`user-profile-assigned-user-cross-unknownId1`)); + + await waitFor(() => expect(onAssigneesChanged).toBeCalledTimes(1)); + + expect(onAssigneesChanged.mock.calls[0][0]).toMatchInlineSnapshot(` + Array [ + Object { + "uid": "unknownId2", + }, + ] + `); + }); + + it('renders two unknown users and one user with a profile', async () => { + const props = { + ...defaultProps, + caseAssignees: [{ uid: 'unknownId1' }, { uid: 'unknownId2' }, { uid: userProfiles[0].uid }], + userProfiles: userProfilesMap, + }; + appMockRender.render(); + + expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); + expect(screen.getByTestId('user-profile-assigned-user-group-unknownId1')).toBeInTheDocument(); + expect(screen.getByTestId('user-profile-assigned-user-group-unknownId2')).toBeInTheDocument(); + }); + + it('calls onAssigneesChanged with both users with profiles and without', async () => { + const onAssigneesChanged = jest.fn(); + const props = { + ...defaultProps, + caseAssignees: [{ uid: 'unknownId1' }, { uid: 'unknownId2' }], + onAssigneesChanged, + userProfiles: userProfilesMap, + }; + appMockRender.render(); + + fireEvent.click(screen.getByTestId('case-view-assignees-edit-button')); + await waitForEuiPopoverOpen(); + fireEvent.change(screen.getByPlaceholderText('Search users'), { + target: { value: 'damaged_raccoon@elastic.co' }, + }); + + fireEvent.click(screen.getByText('Damaged Raccoon')); + + // close the popover + fireEvent.click(screen.getByTestId('case-view-assignees-edit-button')); + await waitForEuiPopoverClose(); + + await waitFor(() => expect(onAssigneesChanged).toBeCalledTimes(1)); + + expect(onAssigneesChanged.mock.calls[0][0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object {}, + "enabled": true, + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "user": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "username": "damaged_raccoon", + }, + }, + Object { + "uid": "unknownId1", + }, + Object { + "uid": "unknownId2", + }, + ] + `); + }); + + it('calls onAssigneesChanged with the unknown users at the end', async () => { + const onAssigneesChanged = jest.fn(); + const props = { + ...defaultProps, + caseAssignees: [{ uid: userProfiles[1].uid }, { uid: 'unknownId1' }, { uid: 'unknownId2' }], + onAssigneesChanged, + userProfiles: userProfilesMap, + }; + appMockRender.render(); + + fireEvent.click(screen.getByTestId('case-view-assignees-edit-button')); + await waitForEuiPopoverOpen(); + + fireEvent.change(screen.getByPlaceholderText('Search users'), { + target: { value: 'damaged_raccoon@elastic.co' }, + }); + + fireEvent.click(screen.getByText('Damaged Raccoon')); + + // close the popover + fireEvent.click(screen.getByTestId('case-view-assignees-edit-button')); + await waitForEuiPopoverClose(); + + await waitFor(() => expect(onAssigneesChanged).toBeCalledTimes(1)); + + expect(onAssigneesChanged.mock.calls[0][0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object {}, + "enabled": true, + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "user": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "username": "damaged_raccoon", + }, + }, + Object { + "data": Object {}, + "enabled": true, + "uid": "u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0", + "user": Object { + "email": "physical_dinosaur@elastic.co", + "full_name": "Physical Dinosaur", + "username": "physical_dinosaur", + }, + }, + Object { + "uid": "unknownId1", + }, + Object { + "uid": "unknownId2", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_view/components/assign_users.tsx b/x-pack/plugins/cases/public/components/case_view/components/assign_users.tsx new file mode 100644 index 0000000000000..520c76fef5124 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/assign_users.tsx @@ -0,0 +1,213 @@ +/* + * 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, { useCallback, useEffect, useState } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiLink, + EuiLoadingSpinner, + EuiSpacer, +} from '@elastic/eui'; + +import { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { CasesPermissions } from '../../../../common'; +import { useAssignees } from '../../../containers/user_profiles/use_assignees'; +import { CaseAssignees } from '../../../../common/api/cases/assignee'; +import * as i18n from '../translations'; +import { SidebarTitle } from './sidebar_title'; +import { UserRepresentation } from '../../user_profiles/user_representation'; +import { useCasesContext } from '../../cases_context/use_cases_context'; +import { Assignee } from '../../user_profiles/types'; +import { SuggestUsersPopover } from './suggest_users_popover'; +import { CurrentUserProfile } from '../../types'; + +interface AssigneesListProps { + assignees: Assignee[]; + currentUserProfile: CurrentUserProfile; + permissions: CasesPermissions; + assignSelf: () => void; + togglePopOver: () => void; + onAssigneeRemoved: (removedAssigneeUID: string) => void; +} + +const AssigneesList: React.FC = ({ + assignees, + currentUserProfile, + permissions, + assignSelf, + togglePopOver, + onAssigneeRemoved, +}) => { + return ( + <> + {assignees.length === 0 ? ( + + + +

+ {i18n.NO_ASSIGNEES} + {permissions.update && ( + <> +
+ + {i18n.ASSIGN_A_USER} + + + )} + {currentUserProfile && permissions.update && ( + <> + {i18n.SPACED_OR} + + {i18n.ASSIGN_YOURSELF} + + + )} +

+
+
+
+ ) : ( + + {assignees.map((assignee) => ( + + + + ))} + + )} + + ); +}; +AssigneesList.displayName = 'AssigneesList'; + +export interface AssignUsersProps { + caseAssignees: CaseAssignees; + currentUserProfile: CurrentUserProfile; + userProfiles: Map; + onAssigneesChanged: (assignees: Assignee[]) => void; + isLoading: boolean; +} + +const AssignUsersComponent: React.FC = ({ + caseAssignees, + userProfiles, + currentUserProfile, + onAssigneesChanged, + isLoading, +}) => { + const { assigneesWithProfiles, assigneesWithoutProfiles, allAssignees } = useAssignees({ + caseAssignees, + userProfiles, + currentUserProfile, + }); + + const [selectedAssignees, setSelectedAssignees] = useState(); + const [needToUpdateAssignees, setNeedToUpdateAssignees] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const togglePopover = useCallback(() => { + setIsPopoverOpen((value) => !value); + setNeedToUpdateAssignees(true); + }, []); + + const onClosePopover = useCallback(() => { + // Order matters here because needToUpdateAssignees will likely be true already + // from the togglePopover call when opening the popover, so if we set the popover to false + // first, we'll get a rerender and then get another after we set needToUpdateAssignees to true again + setNeedToUpdateAssignees(true); + setIsPopoverOpen(false); + }, []); + + const onAssigneeRemoved = useCallback( + (removedAssigneeUID: string) => { + const remainingAssignees = allAssignees.filter( + (assignee) => assignee.uid !== removedAssigneeUID + ); + setSelectedAssignees(remainingAssignees); + setNeedToUpdateAssignees(true); + }, + [allAssignees] + ); + + const onUsersChange = useCallback( + (users: UserProfileWithAvatar[]) => { + // if users are selected then also include the users without profiles + if (users.length > 0) { + setSelectedAssignees([...users, ...assigneesWithoutProfiles]); + } else { + // all users were deselected so lets remove the users without profiles as well + setSelectedAssignees([]); + } + }, + [assigneesWithoutProfiles] + ); + + const assignSelf = useCallback(() => { + if (!currentUserProfile) { + return; + } + + const newAssignees = [currentUserProfile, ...allAssignees]; + setSelectedAssignees(newAssignees); + setNeedToUpdateAssignees(true); + }, [currentUserProfile, allAssignees]); + + const { permissions } = useCasesContext(); + + useEffect(() => { + // selectedAssignees will be undefined on initial render or a rerender occurs, so we only want to update the assignees + // after the users have been changed in some manner not when it is an initial value + if (isPopoverOpen === false && needToUpdateAssignees && selectedAssignees) { + setNeedToUpdateAssignees(false); + onAssigneesChanged(selectedAssignees); + } + }, [isPopoverOpen, needToUpdateAssignees, onAssigneesChanged, selectedAssignees]); + + return ( + + + + + + {isLoading && } + {!isLoading && permissions.update && ( + + + + )} + + + + + ); +}; + +AssignUsersComponent.displayName = 'AssignUsers'; + +export const AssignUsers = React.memo(AssignUsersComponent); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx index db5411525290b..74ff651a0a11c 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx @@ -26,6 +26,7 @@ import { useGetCaseUserActions } from '../../../containers/use_get_case_user_act import { usePostPushToService } from '../../../containers/use_post_push_to_service'; import { useGetConnectors } from '../../../containers/configure/use_connectors'; import { useGetTags } from '../../../containers/use_get_tags'; +import { useBulkGetUserProfiles } from '../../../containers/user_profiles/use_bulk_get_user_profiles'; jest.mock('../../../containers/use_get_case_user_actions'); jest.mock('../../../containers/configure/use_connectors'); @@ -36,6 +37,7 @@ jest.mock('../../user_actions/timestamp', () => ({ jest.mock('../../../common/navigation/hooks'); jest.mock('../../../containers/use_get_action_license'); jest.mock('../../../containers/use_get_tags'); +jest.mock('../../../containers/user_profiles/use_bulk_get_user_profiles'); (useGetTags as jest.Mock).mockReturnValue({ data: ['coke', 'pepsi'], refetch: jest.fn() }); @@ -93,12 +95,14 @@ export const caseProps = { const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock; const useGetConnectorsMock = useGetConnectors as jest.Mock; const usePostPushToServiceMock = usePostPushToService as jest.Mock; +const useBulkGetUserProfilesMock = useBulkGetUserProfiles as jest.Mock; describe('Case View Page activity tab', () => { beforeAll(() => { useGetCaseUserActionsMock.mockReturnValue(defaultUseGetCaseUserActions); useGetConnectorsMock.mockReturnValue({ data: connectorsMock, isLoading: false }); usePostPushToServiceMock.mockReturnValue({ isLoading: false, pushCaseToExternalService }); + useBulkGetUserProfilesMock.mockReturnValue({ isLoading: false, data: new Map() }); }); let appMockRender: AppMockRenderer; diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx index 1e935c15891f4..647763d5461d1 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx @@ -7,6 +7,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingContent } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; +import { isEqual, uniq } from 'lodash'; +import { useGetCurrentUserProfile } from '../../../containers/user_profiles/use_get_current_user_profile'; +import { useBulkGetUserProfiles } from '../../../containers/user_profiles/use_bulk_get_user_profiles'; import { useGetConnectors } from '../../../containers/configure/use_connectors'; import { CaseSeverity } from '../../../../common/api'; import { useCaseViewNavigation } from '../../../common/navigation'; @@ -15,9 +18,9 @@ import { Case, CaseStatuses } from '../../../../common'; import { EditConnector } from '../../edit_connector'; import { CasesNavigation } from '../../links'; import { StatusActionButton } from '../../status/button'; -import { TagList } from '../../tag_list'; +import { EditTags } from './edit_tags'; import { UserActions } from '../../user_actions'; -import { UserList } from '../../user_list'; +import { UserList } from './user_list'; import { useOnUpdateField } from '../use_on_update_field'; import { useCasesContext } from '../../cases_context/use_cases_context'; import * as i18n from '../translations'; @@ -25,6 +28,9 @@ import { getNoneConnector, normalizeActionConnector } from '../../configure_case import { getConnectorById } from '../../utils'; import { SeveritySidebarSelector } from '../../severity/sidebar_selector'; import { useGetCaseUserActions } from '../../../containers/use_get_case_user_actions'; +import { AssignUsers } from './assign_users'; +import { SidebarSection } from './sidebar_section'; +import { Assignee } from '../../user_profiles/types'; export const CaseViewActivity = ({ ruleDetailsNavigation, @@ -47,6 +53,21 @@ export const CaseViewActivity = ({ caseData.connector.id ); + const assignees = useMemo( + () => caseData.assignees.map((assignee) => assignee.uid), + [caseData.assignees] + ); + + const userActionProfileUids = Array.from(userActionsData?.profileUids?.values() ?? []); + const uidsToRetrieve = uniq([...userActionProfileUids, ...assignees]); + + const { data: userProfiles, isFetching: isLoadingUserProfiles } = useBulkGetUserProfiles({ + uids: uidsToRetrieve, + }); + + const { data: currentUserProfile, isFetching: isLoadingCurrentUserProfile } = + useGetCurrentUserProfile(); + const onShowAlertDetails = useCallback( (alertId: string, index: string) => { if (showAlertDetails) { @@ -61,6 +82,11 @@ export const CaseViewActivity = ({ caseData, }); + const isLoadingAssigneeData = + (isLoading && loadingKey === 'assignees') || + isLoadingUserProfiles || + isLoadingCurrentUserProfile; + const changeStatus = useCallback( (status: CaseStatuses) => onUpdateField({ @@ -88,6 +114,16 @@ export const CaseViewActivity = ({ [onUpdateField] ); + const onUpdateAssignees = useCallback( + (newAssignees: Assignee[]) => { + const newAssigneeUids = newAssignees.map((assignee) => ({ uid: assignee.uid })); + if (!isEqual(newAssigneeUids.sort(), assignees.sort())) { + onUpdateField({ key: 'assignees', value: newAssigneeUids }); + } + }, + [assignees, onUpdateField] + ); + const { isLoading: isLoadingConnectors, data: connectors = [] } = useGetConnectors(); const [connectorName, isValidConnector] = useMemo(() => { @@ -118,10 +154,12 @@ export const CaseViewActivity = ({ {isLoadingUserActions && ( )} - {!isLoadingUserActions && userActionsData && ( + {!isLoadingUserActions && userActionsData && userProfiles && ( + + + ) : null} - ({ @@ -32,13 +32,13 @@ jest.mock('@elastic/eui', () => { }; }); const onSubmit = jest.fn(); -const defaultProps: TagListProps = { +const defaultProps: EditTagsProps = { isLoading: false, onSubmit, tags: [], }; -describe('TagList ', () => { +describe('EditTags ', () => { const sampleTags = ['coke', 'pepsi']; const fetchTags = jest.fn(); const formHookMock = getFormMock({ tags: sampleTags }); @@ -55,7 +55,7 @@ describe('TagList ', () => { it('Renders no tags, and then edit', () => { const wrapper = mount( - + ); expect(wrapper.find(`[data-test-subj="no-tags"]`).last().exists()).toBeTruthy(); @@ -67,7 +67,7 @@ describe('TagList ', () => { it('Edit tag on submit', async () => { const wrapper = mount( - + ); wrapper.find(`[data-test-subj="tag-list-edit-button"]`).last().simulate('click'); @@ -78,7 +78,7 @@ describe('TagList ', () => { it('Tag options render with new tags added', () => { const wrapper = mount( - + ); wrapper.find(`[data-test-subj="tag-list-edit-button"]`).last().simulate('click'); @@ -94,7 +94,7 @@ describe('TagList ', () => { }; const wrapper = mount( - + ); @@ -110,7 +110,7 @@ describe('TagList ', () => { it('does not render when the user does not have update permissions', () => { const wrapper = mount( - + ); expect(wrapper.find(`[data-test-subj="tag-list-edit"]`).exists()).toBeFalsy(); diff --git a/x-pack/plugins/cases/public/components/tag_list/index.tsx b/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx similarity index 90% rename from x-pack/plugins/cases/public/components/tag_list/index.tsx rename to x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx index c85f989f88281..0dd651f6c9251 100644 --- a/x-pack/plugins/cases/public/components/tag_list/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx @@ -18,17 +18,27 @@ import { } from '@elastic/eui'; import styled, { css } from 'styled-components'; import { isEqual } from 'lodash/fp'; -import * as i18n from './translations'; -import { Form, FormDataProvider, useForm, getUseField, Field } from '../../common/shared_imports'; -import { schema } from './schema'; -import { useGetTags } from '../../containers/use_get_tags'; +import * as i18n from '../../tags/translations'; +import { + Form, + FormDataProvider, + useForm, + getUseField, + Field, + FormSchema, +} from '../../../common/shared_imports'; +import { useGetTags } from '../../../containers/use_get_tags'; +import { Tags } from '../../tags/tags'; +import { useCasesContext } from '../../cases_context/use_cases_context'; +import { schemaTags } from '../../create/schema'; -import { Tags } from './tags'; -import { useCasesContext } from '../cases_context/use_cases_context'; +export const schema: FormSchema = { + tags: schemaTags, +}; const CommonUseField = getUseField({ component: Field }); -export interface TagListProps { +export interface EditTagsProps { isLoading: boolean; onSubmit: (a: string[]) => void; tags: string[]; @@ -55,7 +65,7 @@ const ColumnFlexGroup = styled(EuiFlexGroup)` `} `; -export const TagList = React.memo(({ isLoading, onSubmit, tags }: TagListProps) => { +export const EditTags = React.memo(({ isLoading, onSubmit, tags }: EditTagsProps) => { const { permissions } = useCasesContext(); const initialState = { tags }; const { form } = useForm({ @@ -194,4 +204,4 @@ export const TagList = React.memo(({ isLoading, onSubmit, tags }: TagListProps) ); }); -TagList.displayName = 'TagList'; +EditTags.displayName = 'EditTags'; diff --git a/x-pack/plugins/cases/public/components/case_view/components/sidebar_section.tsx b/x-pack/plugins/cases/public/components/case_view/components/sidebar_section.tsx new file mode 100644 index 0000000000000..d7b61e16b36b6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/sidebar_section.tsx @@ -0,0 +1,30 @@ +/* + * 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 from 'react'; +import { EuiHorizontalRule } from '@elastic/eui'; + +interface SidebarSectionProps { + children: React.ReactNode; + showHorizontalRule?: boolean; +} + +const SidebarSectionComponent: React.FC = ({ + children, + showHorizontalRule = true, +}) => { + return ( + <> + {children} + {showHorizontalRule ? : null} + + ); +}; + +SidebarSectionComponent.displayName = 'SidebarSection'; + +export const SidebarSection = React.memo(SidebarSectionComponent); diff --git a/x-pack/plugins/cases/public/components/case_view/components/sidebar_title.tsx b/x-pack/plugins/cases/public/components/case_view/components/sidebar_title.tsx new file mode 100644 index 0000000000000..b9ee1dd871b8e --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/sidebar_title.tsx @@ -0,0 +1,25 @@ +/* + * 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 from 'react'; +import { EuiTitle } from '@elastic/eui'; + +interface SidebarTitleProps { + title: string; +} + +const SidebarTitleComponent: React.FC = ({ title }) => { + return ( + +

{title}

+
+ ); +}; + +SidebarTitleComponent.displayName = 'SidebarTitle'; + +export const SidebarTitle = React.memo(SidebarTitleComponent); diff --git a/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx new file mode 100644 index 0000000000000..27115acf5697f --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx @@ -0,0 +1,238 @@ +/* + * 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 from 'react'; +import { AppMockRenderer, createAppMockRenderer } from '../../../common/mock'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { SuggestUsersPopoverProps, SuggestUsersPopover } from './suggest_users_popover'; +import { userProfiles } from '../../../containers/user_profiles/api.mock'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; +import { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { AssigneeWithProfile } from '../../user_profiles/types'; + +jest.mock('../../../containers/user_profiles/api'); + +describe('SuggestUsersPopover', () => { + let appMockRender: AppMockRenderer; + let defaultProps: SuggestUsersPopoverProps; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + + defaultProps = { + isLoading: false, + assignedUsersWithProfiles: [], + isPopoverOpen: true, + onUsersChange: jest.fn(), + togglePopover: jest.fn(), + onClosePopover: jest.fn(), + currentUserProfile: undefined, + }; + }); + + it('calls onUsersChange when 1 user is selected', async () => { + const onUsersChange = jest.fn(); + const props = { ...defaultProps, onUsersChange }; + appMockRender.render(); + + await waitForEuiPopoverOpen(); + + fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'dingo' } }); + + await waitFor(() => { + expect(screen.getByText('wet_dingo@elastic.co')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('wet_dingo@elastic.co')); + + expect(onUsersChange.mock.calls[0][0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object {}, + "enabled": true, + "uid": "u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0", + "user": Object { + "email": "wet_dingo@elastic.co", + "full_name": "Wet Dingo", + "username": "wet_dingo", + }, + }, + ] + `); + }); + + it('calls onUsersChange when multiple users are selected', async () => { + const onUsersChange = jest.fn(); + const props = { ...defaultProps, onUsersChange }; + appMockRender.render(); + + await waitForEuiPopoverOpen(); + + fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'elastic' } }); + + await waitFor(() => { + expect(screen.getByText('wet_dingo@elastic.co')).toBeInTheDocument(); + expect(screen.getByText('damaged_raccoon@elastic.co')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('wet_dingo@elastic.co')); + fireEvent.click(screen.getByText('damaged_raccoon@elastic.co')); + + expect(onUsersChange.mock.calls[1][0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object {}, + "enabled": true, + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "user": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "username": "damaged_raccoon", + }, + }, + Object { + "data": Object {}, + "enabled": true, + "uid": "u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0", + "user": Object { + "email": "wet_dingo@elastic.co", + "full_name": "Wet Dingo", + "username": "wet_dingo", + }, + }, + ] + `); + }); + + it('calls onUsersChange with the current user (Physical Dinosaur) at the beginning', async () => { + const onUsersChange = jest.fn(); + const props = { + ...defaultProps, + assignedUsersWithProfiles: [asAssignee(userProfiles[1]), asAssignee(userProfiles[0])], + currentUserProfile: userProfiles[1], + onUsersChange, + }; + appMockRender.render(); + + await waitForEuiPopoverOpen(); + + fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'elastic' } }); + + await waitFor(() => { + expect(screen.getByText('wet_dingo@elastic.co')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('wet_dingo@elastic.co')); + + expect(onUsersChange.mock.calls[0][0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object {}, + "enabled": true, + "uid": "u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0", + "user": Object { + "email": "physical_dinosaur@elastic.co", + "full_name": "Physical Dinosaur", + "username": "physical_dinosaur", + }, + }, + Object { + "data": Object {}, + "enabled": true, + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "user": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "username": "damaged_raccoon", + }, + }, + Object { + "data": Object {}, + "enabled": true, + "uid": "u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0", + "user": Object { + "email": "wet_dingo@elastic.co", + "full_name": "Wet Dingo", + "username": "wet_dingo", + }, + }, + ] + `); + }); + + it('does not show the assigned users total if there are no assigned users', async () => { + appMockRender.render(); + + await waitForEuiPopoverOpen(); + + expect(screen.queryByText('assigned')).not.toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'dingo' } }); + + await waitFor(() => { + expect(screen.getByText('wet_dingo@elastic.co')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('wet_dingo@elastic.co')); + expect(screen.getByText('1 assigned')).toBeInTheDocument(); + }); + + it('shows the 1 assigned total after clicking on a user', async () => { + appMockRender.render(); + + await waitForEuiPopoverOpen(); + + expect(screen.queryByText('assigned')).not.toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'dingo' } }); + fireEvent.click(screen.getByText('wet_dingo@elastic.co')); + expect(screen.getByText('1 assigned')).toBeInTheDocument(); + }); + + it('shows the 1 assigned total when the users are passed in', async () => { + const props = { + ...defaultProps, + assignedUsersWithProfiles: [{ uid: userProfiles[0].uid, profile: userProfiles[0] }], + }; + appMockRender.render(); + + await waitForEuiPopoverOpen(); + + expect(screen.getByText('1 assigned')).toBeInTheDocument(); + expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); + }); + + it('calls onTogglePopover when clicking the edit button after the popover is already open', async () => { + const togglePopover = jest.fn(); + const props = { + ...defaultProps, + togglePopover, + }; + appMockRender.render(); + + await waitForEuiPopoverOpen(); + + await waitFor(() => { + expect(screen.getByTestId('case-view-assignees-edit-button')).not.toBeDisabled(); + }); + + fireEvent.click(screen.getByTestId('case-view-assignees-edit-button')); + + expect(togglePopover).toBeCalled(); + }); + + it('shows results initially', async () => { + appMockRender.render(); + + await waitForEuiPopoverOpen(); + + await waitFor(() => expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument()); + }); +}); + +const asAssignee = (profile: UserProfileWithAvatar): AssigneeWithProfile => ({ + uid: profile.uid, + profile, +}); diff --git a/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.tsx b/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.tsx new file mode 100644 index 0000000000000..cb824adf0a217 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.tsx @@ -0,0 +1,144 @@ +/* + * 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, { useCallback, useMemo, useState } from 'react'; +import { UserProfilesPopover, UserProfileWithAvatar } from '@kbn/user-profile-components'; + +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import { useSuggestUserProfiles } from '../../../containers/user_profiles/use_suggest_user_profiles'; +import { useCasesContext } from '../../cases_context/use_cases_context'; +import { AssigneeWithProfile } from '../../user_profiles/types'; +import * as i18n from '../translations'; +import { bringCurrentUserToFrontAndSort } from '../../user_profiles/sort'; +import { SelectedStatusMessage } from '../../user_profiles/selected_status_message'; +import { EmptyMessage } from '../../user_profiles/empty_message'; +import { NoMatches } from '../../user_profiles/no_matches'; +import { CurrentUserProfile } from '../../types'; + +const PopoverButton: React.FC<{ togglePopover: () => void; isDisabled: boolean }> = ({ + togglePopover, + isDisabled, +}) => ( + + + +); +PopoverButton.displayName = 'PopoverButton'; + +export interface SuggestUsersPopoverProps { + assignedUsersWithProfiles: AssigneeWithProfile[]; + currentUserProfile: CurrentUserProfile; + isLoading: boolean; + isPopoverOpen: boolean; + onUsersChange: (users: UserProfileWithAvatar[]) => void; + togglePopover: () => void; + onClosePopover: () => void; +} + +const SuggestUsersPopoverComponent: React.FC = ({ + assignedUsersWithProfiles, + currentUserProfile, + isLoading, + isPopoverOpen, + onUsersChange, + togglePopover, + onClosePopover, +}) => { + const { owner } = useCasesContext(); + const [searchTerm, setSearchTerm] = useState(''); + + const selectedProfiles = useMemo(() => { + return bringCurrentUserToFrontAndSort( + currentUserProfile, + assignedUsersWithProfiles.map((assignee) => ({ ...assignee.profile })) + ); + }, [assignedUsersWithProfiles, currentUserProfile]); + + const [selectedUsers, setSelectedUsers] = useState(); + const [isUserTyping, setIsUserTyping] = useState(false); + + const onChange = useCallback( + (users: UserProfileWithAvatar[]) => { + const sortedUsers = bringCurrentUserToFrontAndSort(currentUserProfile, users); + setSelectedUsers(sortedUsers); + onUsersChange(sortedUsers ?? []); + }, + [currentUserProfile, onUsersChange] + ); + + const selectedStatusMessage = useCallback( + (selectedCount: number) => ( + + ), + [] + ); + + const onDebounce = useCallback(() => setIsUserTyping(false), []); + + const { + data: userProfiles, + isLoading: isLoadingSuggest, + isFetching: isFetchingSuggest, + } = useSuggestUserProfiles({ + name: searchTerm, + owners: owner, + onDebounce, + }); + + const isLoadingData = isLoadingSuggest || isLoading || isFetchingSuggest || isUserTyping; + const isDisabled = isLoading; + + const searchResultProfiles = useMemo( + () => bringCurrentUserToFrontAndSort(currentUserProfile, userProfiles), + [currentUserProfile, userProfiles] + ); + + return ( + } + isOpen={isPopoverOpen} + closePopover={onClosePopover} + panelStyle={{ + minWidth: 520, + }} + selectableProps={{ + onChange, + onSearchChange: (term) => { + setSearchTerm(term); + + if (!isEmpty(term)) { + setIsUserTyping(true); + } + }, + selectedStatusMessage, + options: searchResultProfiles, + selectedOptions: selectedUsers ?? selectedProfiles, + isLoading: isLoadingData, + height: 'full', + searchPlaceholder: i18n.SEARCH_USERS, + clearButtonLabel: i18n.REMOVE_ASSIGNEES, + emptyMessage: , + noMatchesMessage: !isLoadingData ? : , + }} + /> + ); +}; + +SuggestUsersPopoverComponent.displayName = 'SuggestUsersPopover'; + +export const SuggestUsersPopover = React.memo(SuggestUsersPopoverComponent); diff --git a/x-pack/plugins/cases/public/components/user_list/index.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/user_list.test.tsx similarity index 93% rename from x-pack/plugins/cases/public/components/user_list/index.test.tsx rename to x-pack/plugins/cases/public/components/case_view/components/user_list.test.tsx index 70f9e7d2fbdfc..ab3b0f90c65a6 100644 --- a/x-pack/plugins/cases/public/components/user_list/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/user_list.test.tsx @@ -7,18 +7,20 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { UserList } from '.'; -import * as i18n from '../case_view/translations'; +import { UserList } from './user_list'; +import * as i18n from '../translations'; describe('UserList ', () => { const title = 'Case Title'; const caseLink = 'http://reddit.com'; const user = { username: 'username', fullName: 'Full Name', email: 'testemail@elastic.co' }; const open = jest.fn(); + beforeEach(() => { jest.clearAllMocks(); window.open = open; }); + it('triggers mailto when email icon clicked', () => { const wrapper = shallow( + i18n.translate('xpack.cases.caseView.sendEmalLinkAria', { + values: { user }, + defaultMessage: 'click to send an email to {user}', + }); + +export const EDIT_ASSIGNEES_ARIA_LABEL = i18n.translate( + 'xpack.cases.caseView.editAssigneesAriaLabel', + { + defaultMessage: 'click to edit assignees', + } +); + +export const NO_ASSIGNEES = i18n.translate('xpack.cases.caseView.noAssignees', { + defaultMessage: 'No users have been assigned.', +}); + +export const ASSIGN_A_USER = i18n.translate('xpack.cases.caseView.assignUser', { + defaultMessage: 'Assign a user', +}); + +export const SPACED_OR = i18n.translate('xpack.cases.caseView.spacedOrText', { + defaultMessage: ' or ', +}); + +export const ASSIGN_YOURSELF = i18n.translate('xpack.cases.caseView.assignYourself', { + defaultMessage: 'assign yourself', +}); + +export const TOTAL_USERS_ASSIGNED = (total: number) => + i18n.translate('xpack.cases.caseView.totalUsersAssigned', { + defaultMessage: '{total} assigned', + values: { total }, + }); diff --git a/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts b/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts index 33620c91d87a2..9180244b9cf45 100644 --- a/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts +++ b/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts @@ -6,6 +6,8 @@ */ import { useCallback } from 'react'; +import deepEqual from 'fast-deep-equal'; + import { CaseConnector } from '../../../common/api'; import { CaseAttributes } from '../../../common/api/cases/case'; import { CaseStatuses } from '../../../common/api/cases/status'; @@ -68,6 +70,13 @@ export const useOnUpdateField = ({ caseData, caseId }: { caseData: Case; caseId: if (caseData.severity !== value) { callUpdate('severity', severityUpdate); } + break; + case 'assignees': + const assigneesUpdate = getTypedPayload(value); + if (!deepEqual(caseData.assignees, value)) { + callUpdate('assignees', assigneesUpdate); + } + break; default: return null; } diff --git a/x-pack/plugins/cases/public/components/create/assignees.test.tsx b/x-pack/plugins/cases/public/components/create/assignees.test.tsx new file mode 100644 index 0000000000000..2ff593651abf7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/assignees.test.tsx @@ -0,0 +1,153 @@ +/* + * 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 from 'react'; +import userEvent from '@testing-library/user-event'; +import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; + +import { useForm, Form, FormHook } from '../../common/shared_imports'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; +import { Assignees } from './assignees'; +import { FormProps } from './schema'; +import { act, waitFor } from '@testing-library/react'; +import * as api from '../../containers/user_profiles/api'; +import { UserProfile } from '@kbn/user-profile-components'; + +jest.mock('../../containers/user_profiles/api'); + +const currentUserProfile = userProfiles[0]; + +describe('Assignees', () => { + let globalForm: FormHook; + let appMockRender: AppMockRenderer; + + const MockHookWrapperComponent: React.FC = ({ children }) => { + const { form } = useForm(); + globalForm = form; + + return
{children}
; + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders', async () => { + const result = appMockRender.render( + + + + ); + + await waitFor(() => { + expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + }); + + expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + }); + + it('does not render the assign yourself link when the current user profile is undefined', async () => { + const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); + spyOnGetCurrentUserProfile.mockResolvedValue(undefined as unknown as UserProfile); + + const result = appMockRender.render( + + + + ); + + await waitFor(() => { + expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + }); + + expect(result.queryByTestId('create-case-assign-yourself-link')).not.toBeInTheDocument(); + expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); + }); + + it('selects the current user correctly', async () => { + const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); + spyOnGetCurrentUserProfile.mockResolvedValue(currentUserProfile); + + const result = appMockRender.render( + + + + ); + + await waitFor(() => { + expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + }); + + act(() => { + userEvent.click(result.getByTestId('create-case-assign-yourself-link')); + }); + + await waitFor(() => { + expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); + }); + }); + + it('disables the assign yourself button if the current user is already selected', async () => { + const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); + spyOnGetCurrentUserProfile.mockResolvedValue(currentUserProfile); + + const result = appMockRender.render( + + + + ); + + await waitFor(() => { + expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + }); + + act(() => { + userEvent.click(result.getByTestId('create-case-assign-yourself-link')); + }); + + await waitFor(() => { + expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); + }); + + expect(result.getByTestId('create-case-assign-yourself-link')).toBeDisabled(); + }); + + it('assignees users correctly', async () => { + const result = appMockRender.render( + + + + ); + + await waitFor(() => { + expect(result.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + }); + + await act(async () => { + await userEvent.type(result.getByTestId('comboBoxSearchInput'), 'dr', { delay: 1 }); + }); + + await waitFor(() => { + expect( + result.getByTestId('comboBoxOptionsList createCaseAssigneesComboBox-optionsList') + ).toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(result.getByText(`${currentUserProfile.user.full_name}`)).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(result.getByText(`${currentUserProfile.user.full_name}`)); + }); + + await waitFor(() => { + expect(globalForm.getFormData()).toEqual({ assignees: [{ uid: currentUserProfile.uid }] }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/assignees.tsx b/x-pack/plugins/cases/public/components/create/assignees.tsx new file mode 100644 index 0000000000000..be4efc0edfed9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/assignees.tsx @@ -0,0 +1,225 @@ +/* + * 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 { isEmpty } from 'lodash'; +import React, { memo, useCallback, useState } from 'react'; +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiFormRow, + EuiLink, + EuiSelectableListItem, + EuiTextColor, +} from '@elastic/eui'; +import { + UserProfileWithAvatar, + UserAvatar, + getUserDisplayName, + UserProfile, +} from '@kbn/user-profile-components'; +import { UseField, FieldConfig, FieldHook } from '../../common/shared_imports'; +import { useSuggestUserProfiles } from '../../containers/user_profiles/use_suggest_user_profiles'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; +import { OptionalFieldLabel } from './optional_field_label'; +import * as i18n from './translations'; +import { bringCurrentUserToFrontAndSort } from '../user_profiles/sort'; +import { useAvailableCasesOwners } from '../app/use_available_owners'; +import { getAllPermissionsExceptFrom } from '../../utils/permissions'; + +interface Props { + isLoading: boolean; +} + +interface FieldProps { + field: FieldHook; + options: EuiComboBoxOptionOption[]; + isLoading: boolean; + isDisabled: boolean; + currentUserProfile?: UserProfile; + selectedOptions: EuiComboBoxOptionOption[]; + setSelectedOptions: React.Dispatch>; + onSearchComboChange: (value: string) => void; +} + +const getConfig = (): FieldConfig => ({ + label: i18n.ASSIGNEES, + defaultValue: [], +}); + +const userProfileToComboBoxOption = (userProfile: UserProfileWithAvatar) => ({ + label: getUserDisplayName(userProfile.user), + value: userProfile.uid, + user: userProfile.user, + data: userProfile.data, +}); + +const comboBoxOptionToAssignee = (option: EuiComboBoxOptionOption) => ({ uid: option.value }); + +const AssigneesFieldComponent: React.FC = React.memo( + ({ + field, + isLoading, + isDisabled, + options, + currentUserProfile, + selectedOptions, + setSelectedOptions, + onSearchComboChange, + }) => { + const { setValue } = field; + + const onComboChange = useCallback( + (currentOptions: EuiComboBoxOptionOption[]) => { + setSelectedOptions(currentOptions); + setValue(currentOptions.map((option) => comboBoxOptionToAssignee(option))); + }, + [setSelectedOptions, setValue] + ); + + const onSelfAssign = useCallback(() => { + if (!currentUserProfile) { + return; + } + + setSelectedOptions((prev) => [ + ...(prev ?? []), + userProfileToComboBoxOption(currentUserProfile), + ]); + + setValue([ + ...(selectedOptions?.map((option) => comboBoxOptionToAssignee(option)) ?? []), + { uid: currentUserProfile.uid }, + ]); + }, [currentUserProfile, selectedOptions, setSelectedOptions, setValue]); + + const renderOption = useCallback( + (option: EuiComboBoxOptionOption, searchValue: string, contentClassName: string) => { + const { user, data, value } = option as EuiComboBoxOptionOption & + UserProfileWithAvatar; + + return ( + } + className={contentClassName} + append={{user.email}} + > + {getUserDisplayName(user)} + + ); + }, + [] + ); + + const isCurrentUserSelected = Boolean( + selectedOptions?.find((option) => option.value === currentUserProfile?.uid) + ); + + return ( + + {i18n.ASSIGN_YOURSELF} + + ) : undefined + } + > + + + ); + } +); + +AssigneesFieldComponent.displayName = 'AssigneesFieldComponent'; + +const AssigneesComponent: React.FC = ({ isLoading: isLoadingForm }) => { + const { owner: owners } = useCasesContext(); + const availableOwners = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete')); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedOptions, setSelectedOptions] = useState(); + const [isUserTyping, setIsUserTyping] = useState(false); + const hasOwners = owners.length > 0; + + const { data: currentUserProfile, isLoading: isLoadingCurrentUserProfile } = + useGetCurrentUserProfile(); + + const onDebounce = useCallback(() => setIsUserTyping(false), []); + + const { + data: userProfiles, + isLoading: isLoadingSuggest, + isFetching: isFetchingSuggest, + } = useSuggestUserProfiles({ + name: searchTerm, + owners: hasOwners ? owners : availableOwners, + onDebounce, + }); + + const options = + bringCurrentUserToFrontAndSort(currentUserProfile, userProfiles)?.map((userProfile) => + userProfileToComboBoxOption(userProfile) + ) ?? []; + + const onSearchComboChange = (value: string) => { + if (!isEmpty(value)) { + setSearchTerm(value); + setIsUserTyping(true); + } + }; + + const isLoading = + isLoadingForm || + isLoadingCurrentUserProfile || + isLoadingSuggest || + isFetchingSuggest || + isUserTyping; + + const isDisabled = isLoadingForm || isLoadingCurrentUserProfile; + + return ( + + ); +}; + +AssigneesComponent.displayName = 'AssigneesComponent'; + +export const Assignees = memo(AssigneesComponent); diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index 78f5a4e9d5c54..d34e216c34af9 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -36,6 +36,7 @@ import { useCasesContext } from '../cases_context/use_cases_context'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { CaseAttachmentsWithoutOwner } from '../../types'; import { Severity } from './severity'; +import { Assignees } from './assignees'; interface ContainerProps { big?: boolean; @@ -86,6 +87,9 @@ export const CreateCaseFormFields: React.FC = React.m children: ( <> + <Container> + <Assignees isLoading={isSubmitting} /> + </Container> <Container> <Tags isLoading={isSubmitting} /> </Container> diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index 6d8520e61a493..8b45258889e5c 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -6,14 +6,12 @@ */ import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; import { act, RenderResult, waitFor, within } from '@testing-library/react'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; -import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { CaseSeverity, CommentType, ConnectorTypes } from '../../../common/api'; import { useKibana } from '../../common/lib/kibana'; -import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; +import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; import { usePostCase } from '../../containers/use_post_case'; import { useCreateAttachments } from '../../containers/use_create_attachments'; import { useCaseConfigure } from '../../containers/configure/use_configure'; @@ -43,6 +41,8 @@ import { connectorsMock } from '../../common/mock/connectors'; import { CaseAttachments } from '../../types'; import { useGetConnectors } from '../../containers/configure/use_connectors'; import { useGetTags } from '../../containers/use_get_tags'; +import { waitForComponentToUpdate } from '../../common/test_utils'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; const sampleId = 'case-id'; @@ -60,6 +60,7 @@ jest.mock('../connectors/jira/use_get_single_issue'); jest.mock('../connectors/jira/use_get_issues'); jest.mock('../connectors/servicenow/use_get_choices'); jest.mock('../../common/lib/kibana'); +jest.mock('../../containers/user_profiles/api'); const useGetConnectorsMock = useGetConnectors as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; @@ -93,37 +94,21 @@ const defaultPostPushToService = { pushCaseToExternalService, }; -const fillForm = (wrapper: ReactWrapper) => { - wrapper - .find(`[data-test-subj="caseTitle"] input`) - .first() - .simulate('change', { target: { value: sampleData.title } }); - - wrapper - .find(`[data-test-subj="caseDescription"] textarea`) - .first() - .simulate('change', { target: { value: sampleData.description } }); - - act(() => { - ( - wrapper.find(EuiComboBox).props() as unknown as { - onChange: (a: EuiComboBoxOptionOption[]) => void; - } - ).onChange(sampleTags.map((tag) => ({ label: tag }))); - }); -}; - const fillFormReactTestingLib = async (renderResult: RenderResult) => { const titleInput = within(renderResult.getByTestId('caseTitle')).getByTestId('input'); + userEvent.type(titleInput, sampleData.title); const descriptionInput = renderResult.container.querySelector( `[data-test-subj="caseDescription"] textarea` ); + if (descriptionInput) { userEvent.type(descriptionInput, sampleData.description); } + const caseTags = renderResult.getByTestId('caseTags'); + for (let i = 0; i < sampleTags.length; i++) { const tagsInput = await within(caseTags).findByTestId('comboBoxInput'); userEvent.type(tagsInput, `${sampleTags[i]}{enter}`); @@ -185,6 +170,7 @@ describe('Create case', () => { expect(renderResult.getByTestId('caseTitle')).toBeTruthy(); expect(renderResult.getByTestId('caseSeverity')).toBeTruthy(); expect(renderResult.getByTestId('caseDescription')).toBeTruthy(); + expect(renderResult.getByTestId('createCaseAssigneesComboBox')).toBeTruthy(); expect(renderResult.getByTestId('caseTags')).toBeTruthy(); expect(renderResult.getByTestId('caseConnectors')).toBeTruthy(); expect(renderResult.getByTestId('case-creation-form-steps')).toBeTruthy(); @@ -241,31 +227,30 @@ describe('Create case', () => { it('does not submits the title when the length is longer than 64 characters', async () => { const longTitle = - 'This is a title that should not be saved as it is longer than 64 characters.'; - - const wrapper = mount( - <TestProviders> - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> - <SubmitCaseButton /> - </FormContext> - </TestProviders> + 'This is a title that should not be saved as it is longer than 64 characters.{enter}'; + + const renderResult = mockedContext.render( + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseFormFields {...defaultCreateCaseForm} /> + <SubmitCaseButton /> + </FormContext> ); + await act(async () => { + const titleInput = within(renderResult.getByTestId('caseTitle')).getByTestId('input'); + await userEvent.type(titleInput, longTitle, { delay: 1 }); + }); + act(() => { - wrapper - .find(`[data-test-subj="caseTitle"] input`) - .first() - .simulate('change', { target: { value: longTitle } }); - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + userEvent.click(renderResult.getByTestId('create-case-submit')); }); await waitFor(() => { - wrapper.update(); - expect(wrapper.find('[data-test-subj="caseTitle"] .euiFormErrorText').text()).toBe( - 'The length of the title is too long. The maximum length is 64.' - ); + expect( + renderResult.getByText('The length of the title is too long. The maximum length is 64.') + ).toBeInTheDocument(); }); + expect(postCase).not.toHaveBeenCalled(); }); @@ -275,18 +260,25 @@ describe('Create case', () => { data: connectorsMock, }); - const wrapper = mount( - <TestProviders> - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> - <SubmitCaseButton /> - </FormContext> - </TestProviders> + const renderResult = mockedContext.render( + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseFormFields {...defaultCreateCaseForm} /> + <SubmitCaseButton /> + </FormContext> ); - fillForm(wrapper); - wrapper.find('[data-test-subj="caseSyncAlerts"] button').first().simulate('click'); - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await fillFormReactTestingLib(renderResult); + + act(() => { + const syncAlertsButton = within(renderResult.getByTestId('caseSyncAlerts')).getByTestId( + 'input' + ); + userEvent.click(syncAlertsButton); + }); + + act(() => { + userEvent.click(renderResult.getByTestId('create-case-submit')); + }); await waitFor(() => expect(postCase).toBeCalledWith({ ...sampleData, settings: { syncAlerts: false } }) @@ -294,22 +286,25 @@ describe('Create case', () => { }); it('should set sync alerts to false when the sync feature setting is false', async () => { + mockedContext = createAppMockRenderer({ + features: { alerts: { sync: false, enabled: true } }, + }); useGetConnectorsMock.mockReturnValue({ ...sampleConnectorData, data: connectorsMock, }); - const wrapper = mount( - <TestProviders features={{ alerts: { sync: false, enabled: true } }}> - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> - <SubmitCaseButton /> - </FormContext> - </TestProviders> + const renderResult = mockedContext.render( + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseFormFields {...defaultCreateCaseForm} /> + <SubmitCaseButton /> + </FormContext> ); - fillForm(wrapper); - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await fillFormReactTestingLib(renderResult); + act(() => { + userEvent.click(renderResult.getByTestId('create-case-submit')); + }); await waitFor(() => expect(postCase).toBeCalledWith({ ...sampleData, settings: { syncAlerts: false } }) @@ -345,18 +340,16 @@ describe('Create case', () => { data: connectorsMock, }); - const wrapper = mount( - <TestProviders> - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> - <SubmitCaseButton /> - </FormContext> - </TestProviders> + const renderResult = mockedContext.render( + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseFormFields {...defaultCreateCaseForm} /> + <SubmitCaseButton /> + </FormContext> ); - fillForm(wrapper); - await act(async () => { - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await fillFormReactTestingLib(renderResult); + act(() => { + userEvent.click(renderResult.getByTestId('create-case-submit')); }); await waitFor(() => @@ -395,17 +388,17 @@ describe('Create case', () => { data: connectorsMock, }); - const wrapper = mount( - <TestProviders> - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> - <SubmitCaseButton /> - </FormContext> - </TestProviders> + const renderResult = mockedContext.render( + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseFormFields {...defaultCreateCaseForm} /> + <SubmitCaseButton /> + </FormContext> ); - fillForm(wrapper); - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await fillFormReactTestingLib(renderResult); + act(() => { + userEvent.click(renderResult.getByTestId('create-case-submit')); + }); await waitFor(() => { expect(postCase).toBeCalledWith(sampleData); expect(pushCaseToExternalService).not.toHaveBeenCalled(); @@ -420,40 +413,43 @@ describe('Create case', () => { data: connectorsMock, }); - const wrapper = mount( - <TestProviders> - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> - <SubmitCaseButton /> - </FormContext> - </TestProviders> + const renderResult = mockedContext.render( + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseFormFields {...defaultCreateCaseForm} /> + <SubmitCaseButton /> + </FormContext> ); - fillForm(wrapper); - expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeFalsy(); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click'); + await fillFormReactTestingLib(renderResult); + + act(() => { + userEvent.click(renderResult.getByTestId('dropdown-connectors')); + }); await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeTruthy(); + expect(renderResult.getByTestId('dropdown-connector-jira-1')).toBeInTheDocument(); }); - wrapper - .find('select[data-test-subj="issueTypeSelect"]') - .first() - .simulate('change', { - target: { value: '10007' }, - }); + act(() => { + userEvent.click(renderResult.getByTestId('dropdown-connector-jira-1')); + }); - wrapper - .find('select[data-test-subj="prioritySelect"]') - .first() - .simulate('change', { - target: { value: '2' }, - }); + await waitFor(() => { + expect(renderResult.getByTestId('issueTypeSelect')).toBeInTheDocument(); + expect(renderResult.getByTestId('prioritySelect')).toBeInTheDocument(); + }); - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + act(() => { + userEvent.selectOptions(renderResult.getByTestId('issueTypeSelect'), ['10007']); + }); + + act(() => { + userEvent.selectOptions(renderResult.getByTestId('prioritySelect'), ['Low']); + }); + + act(() => { + userEvent.click(renderResult.getByTestId('create-case-submit')); + }); await waitFor(() => { expect(postCase).toBeCalledWith({ @@ -462,7 +458,7 @@ describe('Create case', () => { id: 'jira-1', name: 'Jira', type: '.jira', - fields: { issueType: '10007', parent: null, priority: '2' }, + fields: { issueType: '10007', parent: null, priority: 'Low' }, }, }); expect(pushCaseToExternalService).toHaveBeenCalledWith({ @@ -471,7 +467,7 @@ describe('Create case', () => { id: 'jira-1', name: 'Jira', type: '.jira', - fields: { issueType: '10007', parent: null, priority: '2' }, + fields: { issueType: '10007', parent: null, priority: 'Low' }, }, }); expect(onFormSubmitSuccess).toHaveBeenCalledWith({ @@ -487,41 +483,49 @@ describe('Create case', () => { data: connectorsMock, }); - const wrapper = mount( - <TestProviders> - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> - <SubmitCaseButton /> - </FormContext> - </TestProviders> + const renderResult = mockedContext.render( + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseFormFields {...defaultCreateCaseForm} /> + <SubmitCaseButton /> + </FormContext> ); - fillForm(wrapper); - expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy(); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find(`button[data-test-subj="dropdown-connector-resilient-2"]`).simulate('click'); + await fillFormReactTestingLib(renderResult); + + act(() => { + userEvent.click(renderResult.getByTestId('dropdown-connectors')); + }); await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy(); + expect(renderResult.getByTestId('dropdown-connector-resilient-2')).toBeInTheDocument(); }); act(() => { - ( - wrapper.find(EuiComboBox).at(1).props() as unknown as { - onChange: (a: EuiComboBoxOptionOption[]) => void; - } - ).onChange([{ value: '19', label: 'Denial of Service' }]); - }); - - wrapper - .find('select[data-test-subj="severitySelect"]') - .first() - .simulate('change', { - target: { value: '4' }, + userEvent.click(renderResult.getByTestId('dropdown-connector-resilient-2')); + }); + + await waitFor(() => { + expect(renderResult.getByTestId('incidentTypeComboBox')).toBeInTheDocument(); + expect(renderResult.getByTestId('severitySelect')).toBeInTheDocument(); + }); + + const checkbox = within(renderResult.getByTestId('incidentTypeComboBox')).getByTestId( + 'comboBoxSearchInput' + ); + + await act(async () => { + await userEvent.type(checkbox, 'Denial of Service{enter}', { + delay: 2, }); + }); - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + act(() => { + userEvent.selectOptions(renderResult.getByTestId('severitySelect'), ['4']); + }); + + act(() => { + userEvent.click(renderResult.getByTestId('create-case-submit')); + }); await waitFor(() => { expect(postCase).toBeCalledWith({ @@ -530,7 +534,7 @@ describe('Create case', () => { id: 'resilient-2', name: 'My Connector 2', type: '.resilient', - fields: { incidentTypes: ['19'], severityCode: '4' }, + fields: { incidentTypes: ['21'], severityCode: '4' }, }, }); @@ -540,7 +544,7 @@ describe('Create case', () => { id: 'resilient-2', name: 'My Connector 2', type: '.resilient', - fields: { incidentTypes: ['19'], severityCode: '4' }, + fields: { incidentTypes: ['21'], severityCode: '4' }, }, }); @@ -557,54 +561,53 @@ describe('Create case', () => { data: connectorsMock, }); - const wrapper = mount( - <TestProviders> - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> - <SubmitCaseButton /> - </FormContext> - </TestProviders> + const renderResult = mockedContext.render( + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseFormFields {...defaultCreateCaseForm} /> + <SubmitCaseButton /> + </FormContext> ); - fillForm(wrapper); - expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeFalsy(); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-1"]`).simulate('click'); + await fillFormReactTestingLib(renderResult); + + act(() => { + userEvent.click(renderResult.getByTestId('dropdown-connectors')); + }); await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeTruthy(); + expect(renderResult.getByTestId('dropdown-connector-servicenow-1')).toBeInTheDocument(); }); - // we need the choices response to conditionally show the subcategory select + act(() => { + userEvent.click(renderResult.getByTestId('dropdown-connector-servicenow-1')); + }); + + await waitFor(() => { + expect(onChoicesSuccess).toBeDefined(); + }); + + // // we need the choices response to conditionally show the subcategory select act(() => { onChoicesSuccess(useGetChoicesResponse.choices); }); ['severitySelect', 'urgencySelect', 'impactSelect'].forEach((subj) => { - wrapper - .find(`select[data-test-subj="${subj}"]`) - .first() - .simulate('change', { - target: { value: '2' }, - }); - }); - - wrapper - .find('select[data-test-subj="categorySelect"]') - .first() - .simulate('change', { - target: { value: 'software' }, + act(() => { + userEvent.selectOptions(renderResult.getByTestId(subj), ['2']); }); + }); - wrapper - .find('select[data-test-subj="subcategorySelect"]') - .first() - .simulate('change', { - target: { value: 'os' }, - }); + act(() => { + userEvent.selectOptions(renderResult.getByTestId('categorySelect'), ['software']); + }); - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + act(() => { + userEvent.selectOptions(renderResult.getByTestId('subcategorySelect'), ['os']); + }); + + act(() => { + userEvent.click(renderResult.getByTestId('create-case-submit')); + }); await waitFor(() => { expect(postCase).toBeCalledWith({ @@ -652,23 +655,29 @@ describe('Create case', () => { data: connectorsMock, }); - const wrapper = mount( - <TestProviders> - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseFormFields {...defaultCreateCaseForm} /> - <SubmitCaseButton /> - </FormContext> - </TestProviders> + const renderResult = mockedContext.render( + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseFormFields {...defaultCreateCaseForm} /> + <SubmitCaseButton /> + </FormContext> ); - fillForm(wrapper); - expect(wrapper.find(`[data-test-subj="connector-fields-sn-sir"]`).exists()).toBeFalsy(); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-sir"]`).simulate('click'); + await fillFormReactTestingLib(renderResult); + + act(() => { + userEvent.click(renderResult.getByTestId('dropdown-connectors')); + }); + + await waitFor(() => { + expect(renderResult.getByTestId('dropdown-connector-servicenow-sir')).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(renderResult.getByTestId('dropdown-connector-servicenow-sir')); + }); await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-fields-sn-sir"]`).exists()).toBeTruthy(); + expect(onChoicesSuccess).toBeDefined(); }); // we need the choices response to conditionally show the subcategory select @@ -676,33 +685,25 @@ describe('Create case', () => { onChoicesSuccess(useGetChoicesResponse.choices); }); - wrapper - .find('[data-test-subj="destIpCheckbox"] input') - .first() - .simulate('change', { target: { checked: false } }); + act(() => { + userEvent.click(renderResult.getByTestId('destIpCheckbox')); + }); - wrapper - .find('select[data-test-subj="prioritySelect"]') - .first() - .simulate('change', { - target: { value: '1' }, - }); + act(() => { + userEvent.selectOptions(renderResult.getByTestId('prioritySelect'), ['1']); + }); - wrapper - .find('select[data-test-subj="categorySelect"]') - .first() - .simulate('change', { - target: { value: 'Denial of Service' }, - }); + act(() => { + userEvent.selectOptions(renderResult.getByTestId('categorySelect'), ['Denial of Service']); + }); - wrapper - .find('select[data-test-subj="subcategorySelect"]') - .first() - .simulate('change', { - target: { value: '26' }, - }); + act(() => { + userEvent.selectOptions(renderResult.getByTestId('subcategorySelect'), ['26']); + }); - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + act(() => { + userEvent.click(renderResult.getByTestId('create-case-submit')); + }); await waitFor(() => { expect(postCase).toBeCalledWith({ @@ -755,23 +756,31 @@ describe('Create case', () => { data: connectorsMock, }); - const wrapper = mockedContext.render( + const renderResult = mockedContext.render( <FormContext onSuccess={onFormSubmitSuccess} afterCaseCreated={afterCaseCreated}> <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> ); - await fillFormReactTestingLib(wrapper); - expect(wrapper.queryByTestId('connector-fields-jira')).toBeFalsy(); - userEvent.click(wrapper.getByTestId('dropdown-connectors')); - await waitForEuiPopoverOpen(); - await act(async () => { - userEvent.click(wrapper.getByTestId('dropdown-connector-jira-1')); + await fillFormReactTestingLib(renderResult); + + act(() => { + userEvent.click(renderResult.getByTestId('dropdown-connectors')); + }); + + await waitFor(() => { + expect(renderResult.getByTestId('dropdown-connector-jira-1')).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(renderResult.getByTestId('dropdown-connector-jira-1')); + }); + + act(() => { + userEvent.click(renderResult.getByTestId('create-case-submit')); }); - expect(wrapper.getByTestId('connector-fields-jira')).toBeTruthy(); - userEvent.click(wrapper.getByTestId('create-case-submit')); await waitFor(() => { expect(afterCaseCreated).toHaveBeenCalledWith( { @@ -811,19 +820,21 @@ describe('Create case', () => { }, ]; - const wrapper = mockedContext.render( + const renderResult = mockedContext.render( <FormContext onSuccess={onFormSubmitSuccess} attachments={attachments}> <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> ); - await fillFormReactTestingLib(wrapper); + await fillFormReactTestingLib(renderResult); - await act(async () => { - userEvent.click(wrapper.getByTestId('create-case-submit')); + act(() => { + userEvent.click(renderResult.getByTestId('create-case-submit')); }); + await waitForComponentToUpdate(); + expect(createAttachments).toHaveBeenCalledTimes(1); expect(createAttachments).toHaveBeenCalledWith({ caseId: 'case-id', @@ -839,19 +850,21 @@ describe('Create case', () => { }); const attachments: CaseAttachments = []; - const wrapper = mockedContext.render( + const renderResult = mockedContext.render( <FormContext onSuccess={onFormSubmitSuccess} attachments={attachments}> <CreateCaseFormFields {...defaultCreateCaseForm} /> <SubmitCaseButton /> </FormContext> ); - await fillFormReactTestingLib(wrapper); + await fillFormReactTestingLib(renderResult); - await act(async () => { - userEvent.click(wrapper.getByTestId('create-case-submit')); + act(() => { + userEvent.click(renderResult.getByTestId('create-case-submit')); }); + await waitForComponentToUpdate(); + expect(createAttachments).not.toHaveBeenCalled(); }); @@ -873,30 +886,35 @@ describe('Create case', () => { }, ]; - const wrapper = mount( - <TestProviders> - <FormContext - onSuccess={onFormSubmitSuccess} - afterCaseCreated={afterCaseCreated} - attachments={attachments} - > - <CreateCaseFormFields {...defaultCreateCaseForm} /> - <SubmitCaseButton /> - </FormContext> - </TestProviders> + const renderResult = mockedContext.render( + <FormContext + onSuccess={onFormSubmitSuccess} + afterCaseCreated={afterCaseCreated} + attachments={attachments} + > + <CreateCaseFormFields {...defaultCreateCaseForm} /> + <SubmitCaseButton /> + </FormContext> ); - fillForm(wrapper); - expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeFalsy(); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click'); + await fillFormReactTestingLib(renderResult); + + act(() => { + userEvent.click(renderResult.getByTestId('dropdown-connectors')); + }); await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeTruthy(); + expect(renderResult.getByTestId('dropdown-connector-jira-1')).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(renderResult.getByTestId('dropdown-connector-jira-1')); + }); + + act(() => { + userEvent.click(renderResult.getByTestId('create-case-submit')); }); - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); await waitFor(() => { expect(postCase).toHaveBeenCalled(); expect(createAttachments).toHaveBeenCalled(); @@ -933,9 +951,7 @@ describe('Create case', () => { </FormContext> ); - await act(async () => { - fillFormReactTestingLib(result); - }); + await fillFormReactTestingLib(result); await act(async () => { userEvent.click(result.getByTestId('create-case-submit')); @@ -944,4 +960,54 @@ describe('Create case', () => { expect(pushCaseToExternalService).not.toHaveBeenCalled(); }); }); + + describe('Assignees', () => { + it('should submit assignees', async () => { + const renderResult = mockedContext.render( + <FormContext onSuccess={onFormSubmitSuccess}> + <CreateCaseFormFields {...defaultCreateCaseForm} /> + <SubmitCaseButton /> + </FormContext> + ); + + await fillFormReactTestingLib(renderResult); + + const assigneesComboBox = within(renderResult.getByTestId('createCaseAssigneesComboBox')); + + await waitFor(() => { + expect(assigneesComboBox.getByTestId('comboBoxSearchInput')).not.toBeDisabled(); + }); + + await act(async () => { + await userEvent.type(assigneesComboBox.getByTestId('comboBoxSearchInput'), 'dr', { + delay: 1, + }); + }); + + await waitFor(() => { + expect( + renderResult.getByTestId('comboBoxOptionsList createCaseAssigneesComboBox-optionsList') + ).toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(renderResult.getByText(`${userProfiles[0].user.full_name}`)).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(renderResult.getByText(`${userProfiles[0].user.full_name}`)); + }); + + act(() => { + userEvent.click(renderResult.getByTestId('create-case-submit')); + }); + + await waitForComponentToUpdate(); + + expect(postCase).toBeCalledWith({ + ...sampleData, + assignees: [{ uid: userProfiles[0].uid }], + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/create/index.test.tsx b/x-pack/plugins/cases/public/components/create/index.test.tsx index c455ae0c3628d..ee2d2d9a4468c 100644 --- a/x-pack/plugins/cases/public/components/create/index.test.tsx +++ b/x-pack/plugins/cases/public/components/create/index.test.tsx @@ -31,6 +31,7 @@ import { useGetConnectors } from '../../containers/configure/use_connectors'; import { useGetTags } from '../../containers/use_get_tags'; jest.mock('../../containers/api'); +jest.mock('../../containers/user_profiles/api'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/configure/use_configure'); @@ -63,7 +64,7 @@ const fillForm = (wrapper: ReactWrapper) => { act(() => { ( - wrapper.find(EuiComboBox).props() as unknown as { + wrapper.find(EuiComboBox).at(1).props() as unknown as { onChange: (a: EuiComboBoxOptionOption[]) => void; } ).onChange(sampleTags.map((tag) => ({ label: tag }))); diff --git a/x-pack/plugins/cases/public/components/create/mock.ts b/x-pack/plugins/cases/public/components/create/mock.ts index 8f67b3c05d3e4..c54e3206b2b01 100644 --- a/x-pack/plugins/cases/public/components/create/mock.ts +++ b/x-pack/plugins/cases/public/components/create/mock.ts @@ -25,6 +25,7 @@ export const sampleData: CasePostRequest = { syncAlerts: true, }, owner: SECURITY_SOLUTION_OWNER, + assignees: [], }; export const sampleConnectorData = { isLoading: false, data: [] }; diff --git a/x-pack/plugins/cases/public/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx index d72b1cc523f0d..59cf8f919606b 100644 --- a/x-pack/plugins/cases/public/components/create/schema.tsx +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -100,4 +100,5 @@ export const schema: FormSchema<FormProps> = { type: FIELD_TYPES.TOGGLE, defaultValue: true, }, + assignees: {}, }; diff --git a/x-pack/plugins/cases/public/components/create/translations.ts b/x-pack/plugins/cases/public/components/create/translations.ts index 7e0f7e5a6b9d5..780a1bbd1d02f 100644 --- a/x-pack/plugins/cases/public/components/create/translations.ts +++ b/x-pack/plugins/cases/public/components/create/translations.ts @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; export * from '../../common/translations'; +export * from '../user_profiles/translations'; export const STEP_ONE_TITLE = i18n.translate('xpack.cases.create.stepOneTitle', { defaultMessage: 'Case fields', @@ -24,3 +25,7 @@ export const STEP_THREE_TITLE = i18n.translate('xpack.cases.create.stepThreeTitl export const SYNC_ALERTS_LABEL = i18n.translate('xpack.cases.create.syncAlertsLabel', { defaultMessage: 'Sync alert status with case status', }); + +export const ASSIGN_YOURSELF = i18n.translate('xpack.cases.create.assignYourself', { + defaultMessage: 'Assign yourself', +}); diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx index 3d7be7f08084d..5db400203468a 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx @@ -219,21 +219,13 @@ describe('EditConnector ', () => { expect(wrapper.find(`[data-test-subj="has-data-to-push-button"]`).exists()).toBeFalsy(); }); - it('displays the callout message when none is selected', async () => { + it('display the callout message when none is selected', async () => { const defaultProps = getDefaultProps(); const props = { ...defaultProps, connectors: [] }; - const wrapper = mount( - <TestProviders> - <EditConnector {...props} /> - </TestProviders> - ); - wrapper.update(); - await waitFor(() => { - expect(true).toBeTruthy(); - }); - wrapper.update(); + const result = appMockRender.render(<EditConnector {...props} />); + await waitFor(() => { - expect(wrapper.find(`[data-test-subj="push-callouts"]`).exists()).toEqual(true); + expect(result.getByTestId('push-callouts')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/header_page/__snapshots__/editable_title.test.tsx.snap b/x-pack/plugins/cases/public/components/header_page/__snapshots__/editable_title.test.tsx.snap deleted file mode 100644 index 73f466aeec771..0000000000000 --- a/x-pack/plugins/cases/public/components/header_page/__snapshots__/editable_title.test.tsx.snap +++ /dev/null @@ -1,98 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EditableTitle renders 1`] = ` -<I18nProvider> - <Component> - <ThemeProvider - theme={[Function]} - > - <QueryClientProvider - client={ - QueryClient { - "defaultOptions": Object { - "queries": Object { - "retry": false, - }, - }, - "logger": BufferedConsole { - "Console": [Function], - "_buffer": Array [], - "_counters": Object {}, - "_groupDepth": 0, - "_timers": Object {}, - "assert": [Function], - "clear": [Function], - "count": [Function], - "countReset": [Function], - "debug": [Function], - "dir": [Function], - "dirxml": [Function], - "error": [Function], - "group": [Function], - "groupCollapsed": [Function], - "groupEnd": [Function], - "info": [Function], - "log": [Function], - "table": [Function], - "time": [Function], - "timeEnd": [Function], - "timeLog": [Function], - "trace": [Function], - "warn": [Function], - }, - "mutationCache": MutationCache { - "config": Object {}, - "listeners": Array [], - "mutationId": 0, - "mutations": Array [], - "subscribe": [Function], - }, - "mutationDefaults": Array [], - "queryCache": QueryCache { - "config": Object {}, - "listeners": Array [], - "queries": Array [], - "queriesMap": Object {}, - "subscribe": [Function], - }, - "queryDefaults": Array [], - } - } - > - <CasesProvider - value={ - Object { - "externalReferenceAttachmentTypeRegistry": ExternalReferenceAttachmentTypeRegistry { - "collection": Map {}, - "name": "ExternalReferenceAttachmentTypeRegistry", - }, - "features": undefined, - "owner": Array [ - "securitySolution", - ], - "permissions": Object { - "all": true, - "create": true, - "delete": true, - "push": true, - "read": true, - "update": true, - }, - "persistableStateAttachmentTypeRegistry": PersistableStateAttachmentTypeRegistry { - "collection": Map {}, - "name": "PersistableStateAttachmentTypeRegistry", - }, - } - } - > - <Memo(EditableTitle) - isLoading={false} - onSubmit={[MockFunction]} - title="Test title" - /> - </CasesProvider> - </QueryClientProvider> - </ThemeProvider> - </Component> -</I18nProvider> -`; diff --git a/x-pack/plugins/cases/public/components/header_page/__snapshots__/index.test.tsx.snap b/x-pack/plugins/cases/public/components/header_page/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 7e6d9e2b05d94..0000000000000 --- a/x-pack/plugins/cases/public/components/header_page/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,103 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`HeaderPage it renders 1`] = ` -<I18nProvider> - <Component> - <ThemeProvider - theme={[Function]} - > - <QueryClientProvider - client={ - QueryClient { - "defaultOptions": Object { - "queries": Object { - "retry": false, - }, - }, - "logger": BufferedConsole { - "Console": [Function], - "_buffer": Array [], - "_counters": Object {}, - "_groupDepth": 0, - "_timers": Object {}, - "assert": [Function], - "clear": [Function], - "count": [Function], - "countReset": [Function], - "debug": [Function], - "dir": [Function], - "dirxml": [Function], - "error": [Function], - "group": [Function], - "groupCollapsed": [Function], - "groupEnd": [Function], - "info": [Function], - "log": [Function], - "table": [Function], - "time": [Function], - "timeEnd": [Function], - "timeLog": [Function], - "trace": [Function], - "warn": [Function], - }, - "mutationCache": MutationCache { - "config": Object {}, - "listeners": Array [], - "mutationId": 0, - "mutations": Array [], - "subscribe": [Function], - }, - "mutationDefaults": Array [], - "queryCache": QueryCache { - "config": Object {}, - "listeners": Array [], - "queries": Array [], - "queriesMap": Object {}, - "subscribe": [Function], - }, - "queryDefaults": Array [], - } - } - > - <CasesProvider - value={ - Object { - "externalReferenceAttachmentTypeRegistry": ExternalReferenceAttachmentTypeRegistry { - "collection": Map {}, - "name": "ExternalReferenceAttachmentTypeRegistry", - }, - "features": undefined, - "owner": Array [ - "securitySolution", - ], - "permissions": Object { - "all": true, - "create": true, - "delete": true, - "push": true, - "read": true, - "update": true, - }, - "persistableStateAttachmentTypeRegistry": PersistableStateAttachmentTypeRegistry { - "collection": Map {}, - "name": "PersistableStateAttachmentTypeRegistry", - }, - } - } - > - <Memo(HeaderPage) - border={true} - subtitle="Test subtitle" - subtitle2="Test subtitle 2" - title="Test title" - > - <p> - Test supplement - </p> - </Memo(HeaderPage)> - </CasesProvider> - </QueryClientProvider> - </ThemeProvider> - </Component> -</I18nProvider> -`; diff --git a/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx b/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx index f36996c013471..e2893cbbc5aa8 100644 --- a/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx +++ b/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import { shallow } from 'enzyme'; import React from 'react'; import '../../common/mock/match_media'; @@ -27,18 +26,16 @@ describe('EditableTitle', () => { isLoading: false, }; + let appMock: AppMockRenderer; + beforeEach(() => { jest.clearAllMocks(); + appMock = createAppMockRenderer(); }); it('renders', () => { - const wrapper = shallow( - <TestProviders> - <EditableTitle {...defaultProps} /> - </TestProviders> - ); - - expect(wrapper).toMatchSnapshot(); + const renderResult = appMock.render(<EditableTitle {...defaultProps} />); + expect(renderResult.getByText('Test title')).toBeInTheDocument(); }); it('does not show the edit icon when the user does not have edit permissions', () => { @@ -269,12 +266,6 @@ describe('EditableTitle', () => { }); describe('Badges', () => { - let appMock: AppMockRenderer; - - beforeEach(() => { - appMock = createAppMockRenderer(); - }); - it('does not render the badge if the release is ga', () => { const renderResult = appMock.render(<EditableTitle {...defaultProps} />); diff --git a/x-pack/plugins/cases/public/components/header_page/index.test.tsx b/x-pack/plugins/cases/public/components/header_page/index.test.tsx index 707cb9b7c4335..c5c7ddcaab875 100644 --- a/x-pack/plugins/cases/public/components/header_page/index.test.tsx +++ b/x-pack/plugins/cases/public/components/header_page/index.test.tsx @@ -6,7 +6,6 @@ */ import { euiDarkVars } from '@kbn/ui-theme'; -import { shallow } from 'enzyme'; import React from 'react'; import '../../common/mock/match_media'; @@ -18,9 +17,15 @@ jest.mock('../../common/navigation/hooks'); describe('HeaderPage', () => { const mount = useMountAppended(); + let appMock: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMock = createAppMockRenderer(); + }); test('it renders', () => { - const wrapper = shallow( + const result = appMock.render( <TestProviders> <HeaderPage border subtitle="Test subtitle" subtitle2="Test subtitle 2" title="Test title"> <p>{'Test supplement'}</p> @@ -28,7 +33,10 @@ describe('HeaderPage', () => { </TestProviders> ); - expect(wrapper).toMatchSnapshot(); + expect(result.getByText('Test subtitle')).toBeInTheDocument(); + expect(result.getByText('Test subtitle 2')).toBeInTheDocument(); + expect(result.getByText('Test title')).toBeInTheDocument(); + expect(result.getByText('Test supplement')).toBeInTheDocument(); }); test('it renders the back link when provided', () => { @@ -140,12 +148,6 @@ describe('HeaderPage', () => { }); describe('Badges', () => { - let appMock: AppMockRenderer; - - beforeEach(() => { - appMock = createAppMockRenderer(); - }); - it('does not render the badge if the release is ga', () => { const renderResult = appMock.render(<HeaderPage title="Test title" />); diff --git a/x-pack/plugins/cases/public/components/tag_list/tags.tsx b/x-pack/plugins/cases/public/components/tags/tags.tsx similarity index 99% rename from x-pack/plugins/cases/public/components/tag_list/tags.tsx rename to x-pack/plugins/cases/public/components/tags/tags.tsx index ec8a84de1aa88..dd27a4a91ca12 100644 --- a/x-pack/plugins/cases/public/components/tag_list/tags.tsx +++ b/x-pack/plugins/cases/public/components/tags/tags.tsx @@ -14,9 +14,11 @@ interface TagsProps { color?: string; gutterSize?: EuiBadgeGroupProps['gutterSize']; } + const MyEuiBadge = styled(EuiBadge)` max-width: 200px; `; + const TagsComponent: React.FC<TagsProps> = ({ tags, color = 'default', gutterSize }) => ( <> {tags.length > 0 && ( diff --git a/x-pack/plugins/cases/public/components/tag_list/translations.ts b/x-pack/plugins/cases/public/components/tags/translations.ts similarity index 100% rename from x-pack/plugins/cases/public/components/tag_list/translations.ts rename to x-pack/plugins/cases/public/components/tags/translations.ts diff --git a/x-pack/plugins/cases/public/components/types.ts b/x-pack/plugins/cases/public/components/types.ts index d31c297d18b1c..d9ba8890aab31 100644 --- a/x-pack/plugins/cases/public/components/types.ts +++ b/x-pack/plugins/cases/public/components/types.ts @@ -5,6 +5,10 @@ * 2.0. */ +import { UserProfileWithAvatar } from '@kbn/user-profile-components'; + export type { CaseActionConnector } from '../../common/ui/types'; export type ReleasePhase = 'experimental' | 'beta' | 'ga'; + +export type CurrentUserProfile = UserProfileWithAvatar | undefined; diff --git a/x-pack/plugins/cases/public/components/user_actions/assignees.test.tsx b/x-pack/plugins/cases/public/components/user_actions/assignees.test.tsx new file mode 100644 index 0000000000000..57cde43c9fee6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/assignees.test.tsx @@ -0,0 +1,230 @@ +/* + * 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 from 'react'; +import { EuiCommentList } from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; + +import { Actions } from '../../../common/api'; +import { elasticUser, getUserAction } from '../../containers/mock'; +import { TestProviders } from '../../common/mock'; +import { createAssigneesUserActionBuilder, shouldAddAnd, shouldAddComma } from './assignees'; +import { getMockBuilderArgs } from './mock'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + +describe('createAssigneesUserActionBuilder', () => { + describe('shouldAddComma', () => { + it('returns false if there are only 2 items', () => { + expect(shouldAddComma(0, 2)).toBeFalsy(); + }); + + it('returns false it is the last items', () => { + expect(shouldAddComma(2, 3)).toBeFalsy(); + }); + }); + + describe('shouldAddAnd', () => { + it('returns false if there is only 1 item', () => { + expect(shouldAddAnd(0, 1)).toBeFalsy(); + }); + + it('returns false it is not the last items', () => { + expect(shouldAddAnd(1, 3)).toBeFalsy(); + }); + }); + + describe('component', () => { + const builderArgs = getMockBuilderArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders assigned users', () => { + const userAction = getUserAction('assignees', Actions.add, { + createdBy: { + // damaged_raccoon uid + profileUid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + }, + }); + const builder = createAssigneesUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + <TestProviders> + <EuiCommentList comments={createdUserAction} /> + </TestProviders> + ); + + expect(screen.getByText('assigned')).toBeInTheDocument(); + expect(screen.getByText('themselves')).toBeInTheDocument(); + expect(screen.getByText('Physical Dinosaur')).toBeInTheDocument(); + + expect(screen.getByTestId('ua-assignee-physical_dinosaur')).toContainElement( + screen.getByText('and') + ); + }); + + it('renders assigned users with a comma', () => { + const userAction = getUserAction('assignees', Actions.add, { + createdBy: { + // damaged_raccoon uid + profileUid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + }, + payload: { + assignees: [ + // These values map to uids in x-pack/plugins/cases/public/containers/user_profiles/api.mock.ts + { uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }, + { uid: 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0' }, + { uid: 'u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0' }, + ], + }, + }); + const builder = createAssigneesUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + <TestProviders> + <EuiCommentList comments={createdUserAction} /> + </TestProviders> + ); + + expect(screen.getByText('assigned')).toBeInTheDocument(); + expect(screen.getByText('themselves,')).toBeInTheDocument(); + expect(screen.getByText('Physical Dinosaur')).toBeInTheDocument(); + + expect(screen.getByTestId('ua-assignee-physical_dinosaur')).toContainElement( + screen.getByText(',') + ); + + expect(screen.getByText('Wet Dingo')).toBeInTheDocument(); + expect(screen.getByTestId('ua-assignee-wet_dingo')).toContainElement(screen.getByText('and')); + }); + + it('renders unassigned users', () => { + const userAction = getUserAction('assignees', Actions.delete, { + createdBy: { + // damaged_raccoon uid + profileUid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + }, + }); + const builder = createAssigneesUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + <TestProviders> + <EuiCommentList comments={createdUserAction} /> + </TestProviders> + ); + + expect(screen.getByText('unassigned')).toBeInTheDocument(); + expect(screen.getByText('themselves')).toBeInTheDocument(); + expect(screen.getByText('Physical Dinosaur')).toBeInTheDocument(); + + expect(screen.getByTestId('ua-assignee-physical_dinosaur')).toContainElement( + screen.getByText('and') + ); + }); + + it('renders a single assigned user', () => { + const userAction = getUserAction('assignees', Actions.add, { + payload: { + assignees: [ + // only render the physical dinosaur + { uid: 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0' }, + ], + }, + }); + const builder = createAssigneesUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + <TestProviders> + <EuiCommentList comments={createdUserAction} /> + </TestProviders> + ); + + expect(screen.getByText('Physical Dinosaur')).toBeInTheDocument(); + expect(screen.queryByText('themselves,')).not.toBeInTheDocument(); + expect(screen.queryByText('and')).not.toBeInTheDocument(); + }); + + it('renders a single assigned user that is themselves using matching profile uids', () => { + const userAction = getUserAction('assignees', Actions.add, { + createdBy: { + ...elasticUser, + profileUid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + }, + payload: { + assignees: [ + // only render the damaged raccoon which is the current user + { uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }, + ], + }, + }); + const builder = createAssigneesUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + <TestProviders> + <EuiCommentList comments={createdUserAction} /> + </TestProviders> + ); + + expect(screen.getByText('themselves')).toBeInTheDocument(); + expect(screen.queryByText('Physical Dinosaur')).not.toBeInTheDocument(); + expect(screen.queryByText('and')).not.toBeInTheDocument(); + }); + + it('renders a single assigned user that is themselves using matching usernames', () => { + const userAction = getUserAction('assignees', Actions.add, { + createdBy: { + ...elasticUser, + username: 'damaged_raccoon', + }, + payload: { + assignees: [ + // only render the damaged raccoon which is the current user + { uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }, + ], + }, + }); + const builder = createAssigneesUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + <TestProviders> + <EuiCommentList comments={createdUserAction} /> + </TestProviders> + ); + + expect(screen.getByText('themselves')).toBeInTheDocument(); + expect(screen.queryByText('Physical Dinosaur')).not.toBeInTheDocument(); + expect(screen.queryByText('and')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/assignees.tsx b/x-pack/plugins/cases/public/components/user_actions/assignees.tsx index 42580ede0b3f2..e0a499df05633 100644 --- a/x-pack/plugins/cases/public/components/user_actions/assignees.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/assignees.tsx @@ -5,13 +5,165 @@ * 2.0. */ -import type { UserActionBuilder } from './types'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import React, { memo } from 'react'; +import { SnakeToCamelCase } from '../../../common/types'; +import { Actions, AssigneesUserAction, User } from '../../../common/api'; +import { getName } from '../user_profiles/display_name'; +import { Assignee } from '../user_profiles/types'; +import { UserToolTip } from '../user_profiles/user_tooltip'; +import { createCommonUpdateUserActionBuilder } from './common'; +import type { UserActionBuilder, UserActionResponse } from './types'; +import * as i18n from './translations'; +import { getUsernameDataTestSubj } from '../user_profiles/data_test_subject'; + +const FormatListItem: React.FC<{ + children: React.ReactElement; + index: number; + listSize: number; +}> = ({ children, index, listSize }) => { + if (shouldAddAnd(index, listSize)) { + return ( + <> + {i18n.AND} {children} + </> + ); + } else if (shouldAddComma(index, listSize)) { + return ( + <> + {children} + {','} + </> + ); + } + + return children; +}; +FormatListItem.displayName = 'FormatListItem'; + +export const shouldAddComma = (index: number, arrayLength: number) => { + return arrayLength > 2 && index !== arrayLength - 1; +}; + +export const shouldAddAnd = (index: number, arrayLength: number) => { + return arrayLength > 1 && index === arrayLength - 1; +}; + +const Themselves: React.FC<{ + index: number; + numOfAssigness: number; +}> = ({ index, numOfAssigness }) => ( + <FormatListItem index={index} listSize={numOfAssigness}> + <>{i18n.THEMSELVES}</> + </FormatListItem> +); +Themselves.displayName = 'Themselves'; + +const AssigneeComponent: React.FC<{ + assignee: Assignee; + index: number; + numOfAssigness: number; +}> = ({ assignee, index, numOfAssigness }) => ( + <FormatListItem index={index} listSize={numOfAssigness}> + <UserToolTip profile={assignee.profile}> + <strong>{getName(assignee.profile?.user)}</strong> + </UserToolTip> + </FormatListItem> +); +AssigneeComponent.displayName = 'Assignee'; + +interface AssigneesProps { + assignees: Assignee[]; + createdByUser: SnakeToCamelCase<User>; +} + +const AssigneesComponent = ({ assignees, createdByUser }: AssigneesProps) => ( + <> + {assignees.length > 0 && ( + <EuiFlexGroup alignItems="center" gutterSize="xs" wrap> + {assignees.map((assignee, index) => { + const usernameDataTestSubj = getUsernameDataTestSubj(assignee); + + return ( + <EuiFlexItem + data-test-subj={`ua-assignee-${usernameDataTestSubj}`} + grow={false} + key={assignee.uid} + > + <EuiText size="s" className="eui-textBreakWord"> + {doesAssigneeMatchCreatedByUser(assignee, createdByUser) ? ( + <Themselves index={index} numOfAssigness={assignees.length} /> + ) : ( + <AssigneeComponent + assignee={assignee} + index={index} + numOfAssigness={assignees.length} + /> + )} + </EuiText> + </EuiFlexItem> + ); + })} + </EuiFlexGroup> + )} + </> +); +AssigneesComponent.displayName = 'Assignees'; +const Assignees = memo(AssigneesComponent); + +const doesAssigneeMatchCreatedByUser = ( + assignee: Assignee, + createdByUser: SnakeToCamelCase<User> +) => { + return ( + assignee.uid === createdByUser?.profileUid || + // cases created before the assignees functionality will not have the profileUid so we'll need to fallback to the + // next best field + assignee?.profile?.user.username === createdByUser.username + ); +}; + +const getLabelTitle = ( + userAction: UserActionResponse<AssigneesUserAction>, + userProfiles?: Map<string, UserProfileWithAvatar> +) => { + const assignees = userAction.payload.assignees.map((assignee) => { + const profile = userProfiles?.get(assignee.uid); + return { + uid: assignee.uid, + profile, + }; + }); + + return ( + <EuiFlexGroup alignItems="baseline" gutterSize="xs" component="span" responsive={false}> + <EuiFlexItem data-test-subj="ua-assignees-label" grow={false}> + {userAction.action === Actions.add && i18n.ASSIGNED} + {userAction.action === Actions.delete && i18n.UNASSIGNED} + </EuiFlexItem> + <EuiFlexItem grow={false}> + <Assignees createdByUser={userAction.createdBy} assignees={assignees} /> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; export const createAssigneesUserActionBuilder: UserActionBuilder = ({ userAction, handleOutlineComment, + userProfiles, }) => ({ build: () => { - return []; + const assigneesUserAction = userAction as UserActionResponse<AssigneesUserAction>; + const label = getLabelTitle(assigneesUserAction, userProfiles); + const commonBuilder = createCommonUpdateUserActionBuilder({ + userAction, + handleOutlineComment, + label, + icon: 'userAvatar', + }); + + return commonBuilder.build(); }, }); diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx index ef5b4418d454f..d559a0981ad69 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx @@ -63,7 +63,12 @@ const getCreateCommentUserAction = ({ comment: Comment; } & Omit< UserActionBuilderArgs, - 'caseServices' | 'comments' | 'index' | 'handleOutlineComment' + | 'caseServices' + | 'comments' + | 'index' + | 'handleOutlineComment' + | 'userProfiles' + | 'currentUserProfile' >): EuiCommentProps[] => { switch (comment.type) { case CommentType.user: diff --git a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx index 60fc0e92d024b..0991156cd3d4d 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx @@ -32,6 +32,8 @@ const onShowAlertDetails = jest.fn(); const defaultProps = { caseServices: {}, caseUserActions: [], + userProfiles: new Map(), + currentUserProfile: undefined, connectors: [], actionsNavigation: { href: jest.fn(), onClick: jest.fn() }, getRuleDetailsHref: jest.fn(), @@ -440,6 +442,7 @@ describe(`UserActions`, () => { ).toBe('lock'); }); }); + it('shows a lockOpen icon if the action is unisolate/release', async () => { const isolateAction = [getHostIsolationUserAction()]; const props = { diff --git a/x-pack/plugins/cases/public/components/user_actions/index.tsx b/x-pack/plugins/cases/public/components/user_actions/index.tsx index 4a6bc85c7cbd7..1517450c4f62e 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.tsx @@ -81,6 +81,8 @@ export const UserActions = React.memo( ({ caseServices, caseUserActions, + userProfiles, + currentUserProfile, data: caseData, getRuleDetailsHref, actionsNavigation, @@ -183,6 +185,8 @@ export const UserActions = React.memo( externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, userAction, + userProfiles, + currentUserProfile, caseServices, comments: caseData.comments, index, @@ -208,6 +212,8 @@ export const UserActions = React.memo( ), [ caseUserActions, + userProfiles, + currentUserProfile, externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, descriptionCommentListObj, diff --git a/x-pack/plugins/cases/public/components/user_actions/mock.ts b/x-pack/plugins/cases/public/components/user_actions/mock.ts index b3a7909b06929..b963947a6282d 100644 --- a/x-pack/plugins/cases/public/components/user_actions/mock.ts +++ b/x-pack/plugins/cases/public/components/user_actions/mock.ts @@ -10,6 +10,7 @@ import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry'; import { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_framework/persistable_state_registry'; import { basicCase, basicPush, getUserAction } from '../../containers/mock'; +import { userProfiles, userProfilesMap } from '../../containers/user_profiles/api.mock'; import { UserActionBuilderArgs } from './types'; export const getMockBuilderArgs = (): UserActionBuilderArgs => { @@ -63,6 +64,8 @@ export const getMockBuilderArgs = (): UserActionBuilderArgs => { return { userAction, + userProfiles: userProfilesMap, + currentUserProfile: userProfiles[0], externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, caseData: basicCase, diff --git a/x-pack/plugins/cases/public/components/user_actions/tags.tsx b/x-pack/plugins/cases/public/components/user_actions/tags.tsx index d5553a3f6f13d..f9d0203a5647f 100644 --- a/x-pack/plugins/cases/public/components/user_actions/tags.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/tags.tsx @@ -11,7 +11,7 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Actions, TagsUserAction } from '../../../common/api'; import { UserActionBuilder, UserActionResponse } from './types'; import { createCommonUpdateUserActionBuilder } from './common'; -import { Tags } from '../tag_list/tags'; +import { Tags } from '../tags/tags'; import * as i18n from './translations'; const getLabelTitle = (userAction: UserActionResponse<TagsUserAction>) => { diff --git a/x-pack/plugins/cases/public/components/user_actions/translations.ts b/x-pack/plugins/cases/public/components/user_actions/translations.ts index b5b5d902d3a4d..91425c368286d 100644 --- a/x-pack/plugins/cases/public/components/user_actions/translations.ts +++ b/x-pack/plugins/cases/public/components/user_actions/translations.ts @@ -78,3 +78,19 @@ export const CANCEL_BUTTON = i18n.translate('xpack.cases.caseView.delete.cancel' export const CONFIRM_BUTTON = i18n.translate('xpack.cases.caseView.delete.confirm', { defaultMessage: 'Delete', }); + +export const ASSIGNED = i18n.translate('xpack.cases.caseView.assigned', { + defaultMessage: 'assigned', +}); + +export const UNASSIGNED = i18n.translate('xpack.cases.caseView.unAssigned', { + defaultMessage: 'unassigned', +}); + +export const THEMSELVES = i18n.translate('xpack.cases.caseView.assignee.themselves', { + defaultMessage: 'themselves', +}); + +export const AND = i18n.translate('xpack.cases.caseView.assignee.and', { + defaultMessage: 'and', +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/types.ts b/x-pack/plugins/cases/public/components/user_actions/types.ts index 8ba409468851e..7477e6df8d5dc 100644 --- a/x-pack/plugins/cases/public/components/user_actions/types.ts +++ b/x-pack/plugins/cases/public/components/user_actions/types.ts @@ -6,6 +6,7 @@ */ import { EuiCommentProps } from '@elastic/eui'; +import { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { SnakeToCamelCase } from '../../../common/types'; import { ActionTypes, UserActionWithResponse } from '../../../common/api'; import { Case, CaseUserActions, Comment, UseFetchAlertData } from '../../containers/types'; @@ -17,10 +18,13 @@ import { UNSUPPORTED_ACTION_TYPES } from './constants'; import type { OnUpdateFields } from '../case_view/types'; import { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry'; import { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_framework/persistable_state_registry'; +import { CurrentUserProfile } from '../types'; export interface UserActionTreeProps { caseServices: CaseServices; caseUserActions: CaseUserActions[]; + userProfiles: Map<string, UserProfileWithAvatar>; + currentUserProfile: CurrentUserProfile; data: Case; getRuleDetailsHref?: RuleDetailsNavigation['href']; actionsNavigation?: ActionsNavigation; @@ -38,6 +42,8 @@ export type SupportedUserActionTypes = keyof Omit<typeof ActionTypes, Unsupporte export interface UserActionBuilderArgs { caseData: Case; + userProfiles: Map<string, UserProfileWithAvatar>; + currentUserProfile: CurrentUserProfile; externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry; persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry; userAction: CaseUserActions; diff --git a/x-pack/plugins/cases/public/components/tag_list/schema.tsx b/x-pack/plugins/cases/public/components/user_profiles/data_test_subject.ts similarity index 61% rename from x-pack/plugins/cases/public/components/tag_list/schema.tsx rename to x-pack/plugins/cases/public/components/user_profiles/data_test_subject.ts index d7db17bd97cbd..23d952738aa4d 100644 --- a/x-pack/plugins/cases/public/components/tag_list/schema.tsx +++ b/x-pack/plugins/cases/public/components/user_profiles/data_test_subject.ts @@ -5,9 +5,8 @@ * 2.0. */ -import { FormSchema } from '../../common/shared_imports'; -import { schemaTags } from '../create/schema'; +import { Assignee } from './types'; -export const schema: FormSchema = { - tags: schemaTags, +export const getUsernameDataTestSubj = (assignee: Assignee) => { + return assignee.profile?.user.username ?? assignee.uid; }; diff --git a/x-pack/plugins/cases/public/components/user_profiles/display_name.test.ts b/x-pack/plugins/cases/public/components/user_profiles/display_name.test.ts new file mode 100644 index 0000000000000..fec173ac70c61 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/display_name.test.ts @@ -0,0 +1,38 @@ +/* + * 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 { getName } from './display_name'; + +describe('getName', () => { + it('returns unknown when the user is undefined', () => { + expect(getName()).toBe('Unknown'); + }); + + it('returns the full name', () => { + expect(getName({ full_name: 'name', username: 'username' })).toBe('name'); + }); + + it('returns the email if the full name is empty', () => { + expect(getName({ full_name: '', email: 'email', username: 'username' })).toBe('email'); + }); + + it('returns the email if the full name is undefined', () => { + expect(getName({ email: 'email', username: 'username' })).toBe('email'); + }); + + it('returns the username if the full name and email are empty', () => { + expect(getName({ full_name: '', email: '', username: 'username' })).toBe('username'); + }); + + it('returns the username if the full name and email are undefined', () => { + expect(getName({ username: 'username' })).toBe('username'); + }); + + it('returns the username is empty', () => { + expect(getName({ username: '' })).toBe('Unknown'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_profiles/display_name.ts b/x-pack/plugins/cases/public/components/user_profiles/display_name.ts new file mode 100644 index 0000000000000..4abd9f276abaa --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/display_name.ts @@ -0,0 +1,19 @@ +/* + * 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 { getUserDisplayName, UserProfileUserInfo } from '@kbn/user-profile-components'; +import { isEmpty } from 'lodash'; +import * as i18n from './translations'; + +export const getName = (user?: UserProfileUserInfo): string => { + if (!user) { + return i18n.UNKNOWN; + } + + const displayName = getUserDisplayName(user); + return !isEmpty(displayName) ? displayName : i18n.UNKNOWN; +}; diff --git a/x-pack/plugins/cases/public/components/user_profiles/empty_message.test.tsx b/x-pack/plugins/cases/public/components/user_profiles/empty_message.test.tsx new file mode 100644 index 0000000000000..3c0c935d9316e --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/empty_message.test.tsx @@ -0,0 +1,17 @@ +/* + * 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 from 'react'; +import { EmptyMessage } from './empty_message'; +import { render } from '@testing-library/react'; + +describe('EmptyMessage', () => { + it('renders a null component', () => { + const { container } = render(<EmptyMessage />); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_list/translations.ts b/x-pack/plugins/cases/public/components/user_profiles/empty_message.tsx similarity index 52% rename from x-pack/plugins/cases/public/components/user_list/translations.ts rename to x-pack/plugins/cases/public/components/user_profiles/empty_message.tsx index 73610e5959345..a2c713d5a9abc 100644 --- a/x-pack/plugins/cases/public/components/user_list/translations.ts +++ b/x-pack/plugins/cases/public/components/user_profiles/empty_message.tsx @@ -5,10 +5,9 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; +import React from 'react'; -export const SEND_EMAIL_ARIA = (user: string) => - i18n.translate('xpack.cases.caseView.sendEmalLinkAria', { - values: { user }, - defaultMessage: 'click to send an email to {user}', - }); +const EmptyMessageComponent: React.FC = () => null; +EmptyMessageComponent.displayName = 'EmptyMessage'; + +export const EmptyMessage = React.memo(EmptyMessageComponent); diff --git a/x-pack/plugins/cases/public/components/user_profiles/no_matches.test.tsx b/x-pack/plugins/cases/public/components/user_profiles/no_matches.test.tsx new file mode 100644 index 0000000000000..3471aad3fec3c --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/no_matches.test.tsx @@ -0,0 +1,18 @@ +/* + * 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 from 'react'; +import { NoMatches } from './no_matches'; +import { render, screen } from '@testing-library/react'; + +describe('NoMatches', () => { + it('renders the no matches messages', () => { + render(<NoMatches />); + + expect(screen.getByText('No matching users with required access.')); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_profiles/no_matches.tsx b/x-pack/plugins/cases/public/components/user_profiles/no_matches.tsx new file mode 100644 index 0000000000000..638d705fade86 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/no_matches.tsx @@ -0,0 +1,41 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiText, EuiTextAlign } from '@elastic/eui'; +import React from 'react'; +import * as i18n from './translations'; + +const NoMatchesComponent: React.FC = () => { + return ( + <EuiFlexGroup + alignItems="center" + gutterSize="none" + direction="column" + justifyContent="spaceAround" + data-test-subj="case-user-profiles-assignees-popover-no-matches" + > + <EuiFlexItem grow={false}> + <EuiIcon type="userAvatar" size="xl" /> + <EuiSpacer size="xs" /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiTextAlign textAlign="center"> + <EuiText size="s" color="default"> + <strong>{i18n.NO_MATCHING_USERS}</strong> + <br /> + </EuiText> + <EuiText size="s" color="subdued"> + {i18n.TRY_MODIFYING_SEARCH} + </EuiText> + </EuiTextAlign> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; +NoMatchesComponent.displayName = 'NoMatches'; + +export const NoMatches = React.memo(NoMatchesComponent); diff --git a/x-pack/plugins/cases/public/components/user_profiles/selected_status_message.test.tsx b/x-pack/plugins/cases/public/components/user_profiles/selected_status_message.test.tsx new file mode 100644 index 0000000000000..b9611bb683d44 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/selected_status_message.test.tsx @@ -0,0 +1,24 @@ +/* + * 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 from 'react'; +import { render, screen } from '@testing-library/react'; +import { SelectedStatusMessage } from './selected_status_message'; + +describe('SelectedStatusMessage', () => { + it('does not render if the count is 0', () => { + const { container } = render(<SelectedStatusMessage selectedCount={0} message={'hello'} />); + + expect(container.firstChild).toBeNull(); + expect(screen.queryByText('hello')).not.toBeInTheDocument(); + }); + + it('renders the message when the count is great than 0', () => { + render(<SelectedStatusMessage selectedCount={1} message={'hello'} />); + + expect(screen.getByText('hello')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_profiles/selected_status_message.tsx b/x-pack/plugins/cases/public/components/user_profiles/selected_status_message.tsx new file mode 100644 index 0000000000000..87839fb7c3482 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/selected_status_message.tsx @@ -0,0 +1,22 @@ +/* + * 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 from 'react'; + +const SelectedStatusMessageComponent: React.FC<{ + selectedCount: number; + message: string; +}> = ({ selectedCount, message }) => { + if (selectedCount <= 0) { + return null; + } + + return <>{message}</>; +}; +SelectedStatusMessageComponent.displayName = 'SelectedStatusMessage'; + +export const SelectedStatusMessage = React.memo(SelectedStatusMessageComponent); diff --git a/x-pack/plugins/cases/public/components/user_profiles/sort.test.ts b/x-pack/plugins/cases/public/components/user_profiles/sort.test.ts new file mode 100644 index 0000000000000..d2f64a05e7ce1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/sort.test.ts @@ -0,0 +1,118 @@ +/* + * 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 { userProfiles } from '../../containers/user_profiles/api.mock'; +import { bringCurrentUserToFrontAndSort, moveCurrentUserToBeginning } from './sort'; + +describe('sort', () => { + describe('moveCurrentUserToBeginning', () => { + it('returns an empty array if no profiles are provided', () => { + expect(moveCurrentUserToBeginning()).toBeUndefined(); + }); + + it("returns the profiles if the current profile isn't provided", () => { + const profiles = [{ uid: '1' }]; + expect(moveCurrentUserToBeginning(undefined, profiles)).toEqual(profiles); + }); + + it("returns the profiles if the current profile isn't found", () => { + const profiles = [{ uid: '1' }]; + expect(moveCurrentUserToBeginning({ uid: '2' }, profiles)).toEqual(profiles); + }); + + it('moves the current profile to the front', () => { + const profiles = [{ uid: '1' }, { uid: '2' }]; + expect(moveCurrentUserToBeginning({ uid: '2' }, profiles)).toEqual([ + { uid: '2' }, + { uid: '1' }, + ]); + }); + }); + + describe('bringCurrentUserToFrontAndSort', () => { + const unsortedProfiles = [...userProfiles].reverse(); + + it('returns a sorted list of users when the current user is undefined', () => { + expect(bringCurrentUserToFrontAndSort(undefined, unsortedProfiles)).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object {}, + "enabled": true, + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "user": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "username": "damaged_raccoon", + }, + }, + Object { + "data": Object {}, + "enabled": true, + "uid": "u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0", + "user": Object { + "email": "physical_dinosaur@elastic.co", + "full_name": "Physical Dinosaur", + "username": "physical_dinosaur", + }, + }, + Object { + "data": Object {}, + "enabled": true, + "uid": "u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0", + "user": Object { + "email": "wet_dingo@elastic.co", + "full_name": "Wet Dingo", + "username": "wet_dingo", + }, + }, + ] + `); + }); + + it('returns a sorted list of users with the current user at the beginning', () => { + expect(bringCurrentUserToFrontAndSort(userProfiles[2], unsortedProfiles)) + .toMatchInlineSnapshot(` + Array [ + Object { + "data": Object {}, + "enabled": true, + "uid": "u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0", + "user": Object { + "email": "wet_dingo@elastic.co", + "full_name": "Wet Dingo", + "username": "wet_dingo", + }, + }, + Object { + "data": Object {}, + "enabled": true, + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "user": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "username": "damaged_raccoon", + }, + }, + Object { + "data": Object {}, + "enabled": true, + "uid": "u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0", + "user": Object { + "email": "physical_dinosaur@elastic.co", + "full_name": "Physical Dinosaur", + "username": "physical_dinosaur", + }, + }, + ] + `); + }); + + it('returns undefined if profiles is undefined', () => { + expect(bringCurrentUserToFrontAndSort(userProfiles[2], undefined)).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_profiles/sort.ts b/x-pack/plugins/cases/public/components/user_profiles/sort.ts new file mode 100644 index 0000000000000..e1e8018a21e35 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/sort.ts @@ -0,0 +1,53 @@ +/* + * 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 { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { sortBy } from 'lodash'; +import { CurrentUserProfile } from '../types'; + +export const getSortField = (profile: UserProfileWithAvatar) => + profile.user.full_name?.toLowerCase() ?? + profile.user.email?.toLowerCase() ?? + profile.user.username.toLowerCase(); + +export const moveCurrentUserToBeginning = <T extends { uid: string }>( + currentUserProfile?: T, + profiles?: T[] +) => { + if (!profiles) { + return; + } + + if (!currentUserProfile) { + return profiles; + } + + const currentProfileIndex = profiles.find((profile) => profile.uid === currentUserProfile.uid); + + if (!currentProfileIndex) { + return profiles; + } + + const profilesWithoutCurrentUser = profiles.filter( + (profile) => profile.uid !== currentUserProfile.uid + ); + + return [currentUserProfile, ...profilesWithoutCurrentUser]; +}; + +export const bringCurrentUserToFrontAndSort = ( + currentUserProfile: CurrentUserProfile, + profiles?: UserProfileWithAvatar[] +) => moveCurrentUserToBeginning(currentUserProfile, sortProfiles(profiles)); + +export const sortProfiles = (profiles?: UserProfileWithAvatar[]) => { + if (!profiles) { + return; + } + + return sortBy(profiles, getSortField); +}; diff --git a/x-pack/plugins/cases/public/components/user_profiles/translations.ts b/x-pack/plugins/cases/public/components/user_profiles/translations.ts new file mode 100644 index 0000000000000..beded4faf714b --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/translations.ts @@ -0,0 +1,52 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export * from '../../common/translations'; + +export const REMOVE_ASSIGNEE = i18n.translate('xpack.cases.userProfile.removeAssigneeToolTip', { + defaultMessage: 'Remove assignee', +}); + +export const REMOVE_ASSIGNEE_ARIA_LABEL = i18n.translate( + 'xpack.cases.userProfile.removeAssigneeAriaLabel', + { + defaultMessage: 'click to remove assignee', + } +); + +export const MISSING_PROFILE = i18n.translate('xpack.cases.userProfile.missingProfile', { + defaultMessage: 'Unable to find user profile', +}); + +export const SEARCH_USERS = i18n.translate('xpack.cases.userProfile.selectableSearchPlaceholder', { + defaultMessage: 'Search users', +}); + +export const EDIT_ASSIGNEES = i18n.translate('xpack.cases.userProfile.editAssignees', { + defaultMessage: 'Edit assignees', +}); + +export const REMOVE_ASSIGNEES = i18n.translate( + 'xpack.cases.userProfile.suggestUsers.removeAssignees', + { + defaultMessage: 'Remove all assignees', + } +); + +export const ASSIGNEES = i18n.translate('xpack.cases.userProfile.assigneesTitle', { + defaultMessage: 'Assignees', +}); + +export const NO_MATCHING_USERS = i18n.translate('xpack.cases.userProfiles.noMatchingUsers', { + defaultMessage: 'No matching users with required access.', +}); + +export const TRY_MODIFYING_SEARCH = i18n.translate('xpack.cases.userProfiles.tryModifyingSearch', { + defaultMessage: 'Try modifying your search.', +}); diff --git a/x-pack/plugins/cases/public/components/user_profiles/types.ts b/x-pack/plugins/cases/public/components/user_profiles/types.ts new file mode 100644 index 0000000000000..f4acb29809d68 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/types.ts @@ -0,0 +1,17 @@ +/* + * 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 { UserProfileWithAvatar } from '@kbn/user-profile-components'; + +export interface Assignee { + uid: string; + profile?: UserProfileWithAvatar; +} + +export interface AssigneeWithProfile extends Assignee { + profile: UserProfileWithAvatar; +} diff --git a/x-pack/plugins/cases/public/components/user_profiles/unknown_user.tsx b/x-pack/plugins/cases/public/components/user_profiles/unknown_user.tsx new file mode 100644 index 0000000000000..b98eef9efbd9f --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/unknown_user.tsx @@ -0,0 +1,21 @@ +/* + * 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 from 'react'; + +import { UserAvatar, UserAvatarProps } from '@kbn/user-profile-components'; + +interface CaseUnknownUserAvatarProps { + size: UserAvatarProps['size']; +} + +const CaseUnknownUserAvatarComponent: React.FC<CaseUnknownUserAvatarProps> = ({ size }) => { + return <UserAvatar data-test-subj="case-user-profile-avatar-unknown-user" size={size} />; +}; +CaseUnknownUserAvatarComponent.displayName = 'UnknownUserAvatar'; + +export const CaseUnknownUserAvatar = React.memo(CaseUnknownUserAvatarComponent); diff --git a/x-pack/plugins/cases/public/components/user_profiles/user_avatar.test.tsx b/x-pack/plugins/cases/public/components/user_profiles/user_avatar.test.tsx new file mode 100644 index 0000000000000..1337239bf2dcc --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/user_avatar.test.tsx @@ -0,0 +1,32 @@ +/* + * 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 from 'react'; +import { screen } from '@testing-library/react'; +import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; +import { CaseUserAvatar } from './user_avatar'; + +describe('CaseUserAvatar', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + }); + + it('renders the avatar of Damaged Raccoon profile', () => { + appMockRender.render(<CaseUserAvatar size="s" profile={userProfiles[0]} />); + + expect(screen.getByText('DR')).toBeInTheDocument(); + }); + + it('renders the avatar of the unknown profile', () => { + appMockRender.render(<CaseUserAvatar size="s" />); + + expect(screen.getByText('?')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_profiles/user_avatar.tsx b/x-pack/plugins/cases/public/components/user_profiles/user_avatar.tsx new file mode 100644 index 0000000000000..be6a8ddfc9359 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/user_avatar.tsx @@ -0,0 +1,35 @@ +/* + * 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 from 'react'; + +import { UserAvatar, UserProfileWithAvatar, UserAvatarProps } from '@kbn/user-profile-components'; +import { CaseUnknownUserAvatar } from './unknown_user'; + +interface CaseUserAvatarProps { + size: UserAvatarProps['size']; + profile?: UserProfileWithAvatar; +} + +const CaseUserAvatarComponent: React.FC<CaseUserAvatarProps> = ({ size, profile }) => { + const dataTestSubjName = profile?.user.username; + + return profile !== undefined ? ( + <UserAvatar + user={profile.user} + avatar={profile.data.avatar} + data-test-subj={`case-user-profile-avatar-${dataTestSubjName}`} + size={size} + /> + ) : ( + <CaseUnknownUserAvatar size={size} /> + ); +}; + +CaseUserAvatarComponent.displayName = 'CaseUserAvatar'; + +export const CaseUserAvatar = React.memo(CaseUserAvatarComponent); diff --git a/x-pack/plugins/cases/public/components/user_profiles/user_representation.test.tsx b/x-pack/plugins/cases/public/components/user_profiles/user_representation.test.tsx new file mode 100644 index 0000000000000..5bda7ef8d3cba --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/user_representation.test.tsx @@ -0,0 +1,86 @@ +/* + * 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 from 'react'; +import { fireEvent, screen } from '@testing-library/react'; +import { UserRepresentation, UserRepresentationProps } from './user_representation'; +import { userProfiles } from '../../containers/user_profiles/api.mock'; +import { + AppMockRenderer, + createAppMockRenderer, + noUpdateCasesPermissions, +} from '../../common/mock'; + +describe('UserRepresentation', () => { + const dataTestSubjGroup = `user-profile-assigned-user-group-${userProfiles[0].user.username}`; + const dataTestSubjCross = `user-profile-assigned-user-cross-${userProfiles[0].user.username}`; + const dataTestSubjGroupUnknown = `user-profile-assigned-user-group-unknownId`; + const dataTestSubjCrossUnknown = `user-profile-assigned-user-cross-unknownId`; + + let defaultProps: UserRepresentationProps; + let appMockRender: AppMockRenderer; + + beforeEach(() => { + defaultProps = { + assignee: { uid: userProfiles[0].uid, profile: userProfiles[0] }, + onRemoveAssignee: jest.fn(), + }; + + appMockRender = createAppMockRenderer(); + }); + + it('does not show the cross button when the user is not hovering over the row', () => { + appMockRender.render(<UserRepresentation {...defaultProps} />); + + expect(screen.queryByTestId(dataTestSubjCross)).toHaveStyle('opacity: 0'); + }); + + it('show the cross button when the user is hovering over the row', () => { + appMockRender.render(<UserRepresentation {...defaultProps} />); + + fireEvent.mouseEnter(screen.getByTestId(dataTestSubjGroup)); + + expect(screen.getByTestId(dataTestSubjCross)).toHaveStyle('opacity: 1'); + }); + + it('does not show the cross button when the user is hovering over the row and does not have update permissions', () => { + appMockRender = createAppMockRenderer({ permissions: noUpdateCasesPermissions() }); + appMockRender.render(<UserRepresentation {...defaultProps} />); + + fireEvent.mouseEnter(screen.getByTestId(dataTestSubjGroup)); + + expect(screen.queryByTestId(dataTestSubjCross)).not.toBeInTheDocument(); + }); + + it('show the cross button when hovering over the row of an unknown user', () => { + appMockRender.render( + <UserRepresentation {...{ ...defaultProps, assignee: { uid: 'unknownId' } }} /> + ); + + fireEvent.mouseEnter(screen.getByTestId(dataTestSubjGroupUnknown)); + + expect(screen.getByTestId(dataTestSubjCrossUnknown)).toHaveStyle('opacity: 1'); + }); + + it('shows and then removes the cross button when the user hovers and removes the mouse from over the row', () => { + appMockRender.render(<UserRepresentation {...defaultProps} />); + + fireEvent.mouseEnter(screen.getByTestId(dataTestSubjGroup)); + expect(screen.getByTestId(dataTestSubjCross)).toHaveStyle('opacity: 1'); + + fireEvent.mouseLeave(screen.getByTestId(dataTestSubjGroup)); + expect(screen.queryByTestId(dataTestSubjCross)).toHaveStyle('opacity: 0'); + }); + + it("renders unknown for the user's information", () => { + appMockRender.render( + <UserRepresentation {...{ ...defaultProps, assignee: { uid: 'unknownId' } }} /> + ); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_profiles/user_representation.tsx b/x-pack/plugins/cases/public/components/user_profiles/user_representation.tsx new file mode 100644 index 0000000000000..8ca7fdd435fc4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/user_representation.tsx @@ -0,0 +1,103 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; +import { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { CaseUserAvatar } from './user_avatar'; +import { UserToolTip } from './user_tooltip'; +import { getName } from './display_name'; +import * as i18n from './translations'; +import { Assignee } from './types'; +import { useCasesContext } from '../cases_context/use_cases_context'; + +const UserAvatarWithName: React.FC<{ profile?: UserProfileWithAvatar }> = ({ profile }) => { + return ( + <EuiFlexGroup alignItems="center" gutterSize="s"> + <EuiFlexItem grow={false}> + <CaseUserAvatar size={'s'} profile={profile} /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFlexGroup direction={'column'} gutterSize="none"> + <EuiFlexItem> + <EuiText size="s" className="eui-textBreakWord"> + {getName(profile?.user)} + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; +UserAvatarWithName.displayName = 'UserAvatarWithName'; + +export interface UserRepresentationProps { + assignee: Assignee; + onRemoveAssignee: (removedAssigneeUID: string) => void; +} + +const UserRepresentationComponent: React.FC<UserRepresentationProps> = ({ + assignee, + onRemoveAssignee, +}) => { + const { permissions } = useCasesContext(); + const [isHovering, setIsHovering] = useState(false); + + const removeAssigneeCallback = useCallback( + () => onRemoveAssignee(assignee.uid), + [onRemoveAssignee, assignee.uid] + ); + + const onFocus = useCallback(() => setIsHovering(true), []); + const onFocusLeave = useCallback(() => setIsHovering(false), []); + + const usernameDataTestSubj = assignee.profile?.user.username ?? assignee.uid; + + return ( + <EuiFlexGroup + onMouseEnter={onFocus} + onMouseLeave={onFocusLeave} + alignItems="center" + gutterSize="s" + justifyContent="spaceBetween" + data-test-subj={`user-profile-assigned-user-group-${usernameDataTestSubj}`} + > + <EuiFlexItem grow={false}> + <UserToolTip profile={assignee.profile}> + <UserAvatarWithName profile={assignee.profile} /> + </UserToolTip> + </EuiFlexItem> + {permissions.update && ( + <EuiFlexItem grow={false}> + <EuiToolTip + position="left" + content={i18n.REMOVE_ASSIGNEE} + data-test-subj={`user-profile-assigned-user-cross-tooltip-${usernameDataTestSubj}`} + > + <EuiButtonIcon + css={{ + opacity: isHovering ? 1 : 0, + }} + onFocus={onFocus} + onBlur={onFocusLeave} + data-test-subj={`user-profile-assigned-user-cross-${usernameDataTestSubj}`} + aria-label={i18n.REMOVE_ASSIGNEE_ARIA_LABEL} + iconType="cross" + color="danger" + iconSize="m" + onClick={removeAssigneeCallback} + /> + </EuiToolTip> + </EuiFlexItem> + )} + </EuiFlexGroup> + ); +}; + +UserRepresentationComponent.displayName = 'UserRepresentation'; + +export const UserRepresentation = React.memo(UserRepresentationComponent); diff --git a/x-pack/plugins/cases/public/components/user_profiles/user_tooltip.test.tsx b/x-pack/plugins/cases/public/components/user_profiles/user_tooltip.test.tsx new file mode 100644 index 0000000000000..17d26a39c48f6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/user_tooltip.test.tsx @@ -0,0 +1,174 @@ +/* + * 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 { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; +import { UserToolTip } from './user_tooltip'; + +describe('UserToolTip', () => { + it('renders the tooltip when hovering', async () => { + const profile: UserProfileWithAvatar = { + uid: '1', + enabled: true, + data: { + avatar: { + initials: 'SU', + }, + }, + user: { + username: 'user', + email: 'some.user@google.com', + full_name: 'Some Super User', + }, + }; + + render( + <UserToolTip profile={profile}> + <strong>{'case user'}</strong> + </UserToolTip> + ); + + fireEvent.mouseOver(screen.getByText('case user')); + + await waitFor(() => screen.getByTestId('user-profile-tooltip')); + expect(screen.getByText('Some Super User')).toBeInTheDocument(); + expect(screen.getByText('some.user@google.com')).toBeInTheDocument(); + expect(screen.getByText('SU')).toBeInTheDocument(); + }); + + it('only shows the display name if full name is missing', async () => { + const profile: UserProfileWithAvatar = { + uid: '1', + enabled: true, + data: { + avatar: { + initials: 'SU', + }, + }, + user: { + username: 'user', + email: 'some.user@google.com', + }, + }; + + render( + <UserToolTip profile={profile}> + <strong>{'case user'}</strong> + </UserToolTip> + ); + + fireEvent.mouseOver(screen.getByText('case user')); + + await waitFor(() => screen.getByTestId('user-profile-tooltip')); + expect(screen.queryByText('Some Super User')).not.toBeInTheDocument(); + expect(screen.getByText('some.user@google.com')).toBeInTheDocument(); + expect(screen.getByText('SU')).toBeInTheDocument(); + }); + + it('only shows the full name if display name is missing', async () => { + const profile: UserProfileWithAvatar = { + uid: '1', + enabled: true, + data: { + avatar: { + initials: 'SU', + }, + }, + user: { + username: 'user', + full_name: 'Some Super User', + email: 'some.user@google.com', + }, + }; + + render( + <UserToolTip profile={profile}> + <strong>{'case user'}</strong> + </UserToolTip> + ); + + fireEvent.mouseOver(screen.getByText('case user')); + + await waitFor(() => screen.getByTestId('user-profile-tooltip')); + expect(screen.getByText('Some Super User')).toBeInTheDocument(); + expect(screen.getByText('some.user@google.com')).toBeInTheDocument(); + expect(screen.getByText('SU')).toBeInTheDocument(); + }); + + it('only shows the email once when display name and full name are not defined', async () => { + const profile: UserProfileWithAvatar = { + uid: '1', + enabled: true, + data: { + avatar: { + initials: 'SU', + }, + }, + user: { + username: 'user', + email: 'some.user@google.com', + }, + }; + + render( + <UserToolTip profile={profile}> + <strong>{'case user'}</strong> + </UserToolTip> + ); + + fireEvent.mouseOver(screen.getByText('case user')); + + await waitFor(() => screen.getByTestId('user-profile-tooltip')); + expect(screen.queryByText('Some Super User')).not.toBeInTheDocument(); + expect(screen.getByText('some.user@google.com')).toBeInTheDocument(); + expect(screen.getByText('SU')).toBeInTheDocument(); + }); + + it('only shows the username once when all other fields are undefined', async () => { + const profile: UserProfileWithAvatar = { + uid: '1', + enabled: true, + data: { + avatar: { + initials: 'SU', + }, + }, + user: { + username: 'user', + }, + }; + + render( + <UserToolTip profile={profile}> + <strong>{'case user'}</strong> + </UserToolTip> + ); + + fireEvent.mouseOver(screen.getByText('case user')); + + await waitFor(() => screen.getByTestId('user-profile-tooltip')); + expect(screen.queryByText('Some Super User')).not.toBeInTheDocument(); + expect(screen.queryByText('some.user@google.com')).not.toBeInTheDocument(); + expect(screen.getByText('user')).toBeInTheDocument(); + expect(screen.getByText('SU')).toBeInTheDocument(); + }); + + it('shows an unknown users display name and avatar', async () => { + render( + <UserToolTip> + <strong>{'case user'}</strong> + </UserToolTip> + ); + + fireEvent.mouseOver(screen.getByText('case user')); + + await waitFor(() => screen.getByTestId('user-profile-tooltip')); + expect(screen.getByText('Unable to find user profile')).toBeInTheDocument(); + expect(screen.getByText('?')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_profiles/user_tooltip.tsx b/x-pack/plugins/cases/public/components/user_profiles/user_tooltip.tsx new file mode 100644 index 0000000000000..9c837997b9a1f --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/user_tooltip.tsx @@ -0,0 +1,105 @@ +/* + * 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 from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; +import { UserProfileUserInfo, UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { CaseUserAvatar } from './user_avatar'; +import { getName } from './display_name'; +import * as i18n from './translations'; + +const UserFullInformation: React.FC<{ profile?: UserProfileWithAvatar }> = React.memo( + ({ profile }) => { + if (profile?.user.full_name) { + return ( + <EuiText size="s" className="eui-textBreakWord"> + <strong data-test-subj="user-profile-tooltip-full-name">{profile.user.full_name}</strong> + </EuiText> + ); + } + + return ( + <EuiText + size="s" + className="eui-textBreakWord" + data-test-subj="user-profile-tooltip-single-name" + > + <strong>{getNameOrMissingText(profile?.user)}</strong> + </EuiText> + ); + } +); + +const getNameOrMissingText = (user?: UserProfileUserInfo) => { + if (!user) { + return i18n.MISSING_PROFILE; + } + + return getName(user); +}; + +UserFullInformation.displayName = 'UserFullInformation'; + +interface UserFullRepresentationProps { + profile?: UserProfileWithAvatar; +} + +const UserFullRepresentationComponent: React.FC<UserFullRepresentationProps> = ({ profile }) => { + return ( + <EuiFlexGroup alignItems="center" gutterSize="s"> + <EuiFlexItem grow={false} data-test-subj="user-profile-tooltip-avatar"> + <CaseUserAvatar size={'m'} profile={profile} /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFlexGroup direction={'column'} gutterSize="none"> + <EuiFlexItem> + <UserFullInformation profile={profile} /> + </EuiFlexItem> + {profile && displayEmail(profile) && ( + <EuiFlexItem grow={false}> + <EuiText + size="s" + className="eui-textBreakWord" + data-test-subj="user-profile-tooltip-email" + > + {profile.user.email} + </EuiText> + </EuiFlexItem> + )} + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; + +UserFullRepresentationComponent.displayName = 'UserFullRepresentation'; + +const displayEmail = (profile?: UserProfileWithAvatar) => { + return profile?.user.full_name && profile?.user.email; +}; + +export interface UserToolTipProps { + children: React.ReactElement; + profile?: UserProfileWithAvatar; +} + +const UserToolTipComponent: React.FC<UserToolTipProps> = ({ children, profile }) => { + return ( + <EuiToolTip + display="inlineBlock" + position="top" + content={<UserFullRepresentationComponent profile={profile} />} + data-test-subj="user-profile-tooltip" + > + {children} + </EuiToolTip> + ); +}; + +UserToolTipComponent.displayName = 'UserToolTip'; +export const UserToolTip = React.memo(UserToolTipComponent); diff --git a/x-pack/plugins/cases/public/containers/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/__mocks__/api.ts index f781daac15697..2b43135123080 100644 --- a/x-pack/plugins/cases/public/containers/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/__mocks__/api.ts @@ -26,7 +26,6 @@ import { casesStatus, caseUserActions, pushedCase, - respReporters, tags, } from '../mock'; import { ResolvedCase, SeverityAll } from '../../../common/ui/types'; @@ -34,11 +33,12 @@ import { CasePatchRequest, CasePostRequest, CommentRequest, - User, CaseStatuses, SingleCaseMetricsResponse, } from '../../../common/api'; import type { ValidFeatureId } from '@kbn/rule-data-utils'; +import { UserProfile } from '@kbn/security-plugin/common'; +import { userProfiles } from '../user_profiles/api.mock'; export const getCase = async ( caseId: string, @@ -62,8 +62,7 @@ export const getCasesStatus = async (signal: AbortSignal): Promise<CasesStatus> export const getTags = async (signal: AbortSignal): Promise<string[]> => Promise.resolve(tags); -export const getReporters = async (signal: AbortSignal): Promise<User[]> => - Promise.resolve(respReporters); +export const findAssignees = async (): Promise<UserProfile[]> => userProfiles; export const getCaseUserActions = async ( caseId: string, @@ -75,6 +74,7 @@ export const getCases = async ({ severity: SeverityAll, search: '', searchFields: [], + assignees: [], reporters: [], status: CaseStatuses.open, tags: [], diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index 45cde4c4f94cb..e51224dc593dc 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -23,7 +23,6 @@ import { getCase, getCases, getCaseUserActions, - getReporters, getTags, patchCase, patchCasesStatus, @@ -48,8 +47,6 @@ import { cases, caseUserActions, pushedCase, - reporters, - respReporters, tags, caseUserActionsSnake, casesStatusSnake, @@ -200,6 +197,7 @@ describe('Cases API', () => { query: { ...DEFAULT_QUERY_PARAMS, searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + assignees: [], reporters: [], tags: [], owner: [SECURITY_SOLUTION_OWNER], @@ -212,7 +210,8 @@ describe('Cases API', () => { await getCases({ filterOptions: { ...DEFAULT_FILTER_OPTIONS, - reporters: [...respReporters, { username: null, full_name: null, email: null }], + assignees: ['123'], + reporters: [{ username: 'username', full_name: null, email: null }], tags, status: CaseStatuses.open, search: 'hello', @@ -225,7 +224,8 @@ describe('Cases API', () => { method: 'GET', query: { ...DEFAULT_QUERY_PARAMS, - reporters, + assignees: ['123'], + reporters: ['username'], tags: ['coke', 'pepsi'], search: 'hello', searchFields: DEFAULT_FILTER_OPTIONS.searchFields, @@ -250,6 +250,7 @@ describe('Cases API', () => { query: { ...DEFAULT_QUERY_PARAMS, searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + assignees: [], reporters: [], tags: [], severity: CaseSeverity.HIGH, @@ -272,6 +273,7 @@ describe('Cases API', () => { query: { ...DEFAULT_QUERY_PARAMS, searchFields: DEFAULT_FILTER_OPTIONS.searchFields, + assignees: [], reporters: [], tags: [], }, @@ -285,7 +287,8 @@ describe('Cases API', () => { await getCases({ filterOptions: { ...DEFAULT_FILTER_OPTIONS, - reporters: [...respReporters, { username: null, full_name: null, email: null }], + assignees: ['123'], + reporters: [{ username: undefined, full_name: undefined, email: undefined }], tags: weirdTags, status: CaseStatuses.open, search: 'hello', @@ -298,7 +301,8 @@ describe('Cases API', () => { method: 'GET', query: { ...DEFAULT_QUERY_PARAMS, - reporters, + assignees: ['123'], + reporters: [], tags: ['(', '"double"'], search: 'hello', searchFields: DEFAULT_FILTER_OPTIONS.searchFields, @@ -378,29 +382,6 @@ describe('Cases API', () => { }); }); - describe('getReporters', () => { - beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(respReporters); - }); - - test('should be called with correct check url, method, signal', async () => { - await getReporters(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]); - expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/reporters`, { - method: 'GET', - signal: abortCtrl.signal, - query: { - owner: [SECURITY_SOLUTION_OWNER], - }, - }); - }); - - test('should return correct response', async () => { - const resp = await getReporters(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]); - expect(resp).toEqual(respReporters); - }); - }); - describe('getTags', () => { beforeEach(() => { fetchMock.mockClear(); diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index 70b9c4033a424..2b7e8910fb9da 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -164,6 +164,7 @@ export const getCases = async ({ search: '', searchFields: [], severity: SeverityAll, + assignees: [], reporters: [], status: StatusAll, tags: [], @@ -180,6 +181,7 @@ export const getCases = async ({ const query = { ...(filterOptions.status !== StatusAll ? { status: filterOptions.status } : {}), ...(filterOptions.severity !== SeverityAll ? { severity: filterOptions.severity } : {}), + assignees: filterOptions.assignees, reporters: filterOptions.reporters.map((r) => r.username ?? '').filter((r) => r !== ''), tags: filterOptions.tags, ...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}), diff --git a/x-pack/plugins/cases/public/containers/constants.ts b/x-pack/plugins/cases/public/containers/constants.ts index 3a04b411cb8e7..a87d773303447 100644 --- a/x-pack/plugins/cases/public/containers/constants.ts +++ b/x-pack/plugins/cases/public/containers/constants.ts @@ -24,3 +24,4 @@ export const CASE_TAGS_CACHE_KEY = 'case-tags'; export const USER_PROFILES_CACHE_KEY = 'user-profiles'; export const USER_PROFILES_SUGGEST_CACHE_KEY = 'suggest'; export const USER_PROFILES_BULK_GET_CACHE_KEY = 'bulk-get'; +export const USER_PROFILES_GET_CURRENT_CACHE_KEY = 'get-current'; diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 92e601dd0c9e9..812349e96fce7 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -228,7 +228,8 @@ export const basicCase: Case = { settings: { syncAlerts: true, }, - assignees: [], + // damaged_raccoon uid + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], }; export const caseWithAlerts = { @@ -553,13 +554,6 @@ export const pushedCaseSnake = { external_service: { ...basicPushSnake, connector_id: pushConnectorId }, }; -export const reporters: string[] = ['alexis', 'kim', 'maria', 'steph']; -export const respReporters = [ - { username: 'alexis', full_name: null, email: null }, - { username: 'kim', full_name: null, email: null }, - { username: 'maria', full_name: null, email: null }, - { username: 'steph', full_name: null, email: null }, -]; export const casesSnake: CasesResponse = [ basicCaseSnake, { ...pushedCaseSnake, id: '1', totalComment: 0, comments: [] }, @@ -688,6 +682,19 @@ export const getUserAction = ( payload: { title: 'a title' }, ...overrides, }; + case ActionTypes.assignees: + return { + ...commonProperties, + type: ActionTypes.assignees, + payload: { + assignees: [ + // These values map to uids in x-pack/plugins/cases/public/containers/user_profiles/api.mock.ts + { uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }, + { uid: 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0' }, + ], + }, + ...overrides, + }; default: return { diff --git a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx index c5dbf017da8c9..a9d80181b58f7 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx @@ -62,6 +62,7 @@ describe('useGetCaseUserActions', () => { caseServices: {}, hasDataToPush: true, participants: [elasticUser], + profileUids: new Set(), }, isError: false, isLoading: false, @@ -87,6 +88,84 @@ describe('useGetCaseUserActions', () => { expect(addError).toHaveBeenCalled(); }); + describe('getProfileUids', () => { + it('aggregates the uids from an assignment add user action', async () => { + jest + .spyOn(api, 'getCaseUserActions') + .mockReturnValue( + Promise.resolve([...caseUserActions, getUserAction('assignees', Actions.add)]) + ); + + await act(async () => { + const { result } = renderHook<string, UseGetCaseUserActions>( + () => useGetCaseUserActions(basicCase.id, basicCase.connector.id), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.data?.profileUids).toMatchInlineSnapshot(` + Set { + "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0", + } + `); + }); + }); + }); + + it('ignores duplicate uids', async () => { + jest + .spyOn(api, 'getCaseUserActions') + .mockReturnValue( + Promise.resolve([ + ...caseUserActions, + getUserAction('assignees', Actions.add), + getUserAction('assignees', Actions.add), + ]) + ); + + await act(async () => { + const { result } = renderHook<string, UseGetCaseUserActions>( + () => useGetCaseUserActions(basicCase.id, basicCase.connector.id), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.data?.profileUids).toMatchInlineSnapshot(` + Set { + "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0", + } + `); + }); + }); + }); + + it('aggregates the uids from an assignment delete user action', async () => { + jest + .spyOn(api, 'getCaseUserActions') + .mockReturnValue( + Promise.resolve([...caseUserActions, getUserAction('assignees', Actions.delete)]) + ); + + await act(async () => { + const { result } = renderHook<string, UseGetCaseUserActions>( + () => useGetCaseUserActions(basicCase.id, basicCase.connector.id), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.data?.profileUids).toMatchInlineSnapshot(` + Set { + "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0", + } + `); + }); + }); + }); + }); + describe('getPushedInfo', () => { it('Correctly marks first/last index - hasDataToPush: false', () => { const userActions = [...caseUserActions, getUserAction('pushed', Actions.push_to_service)]; diff --git a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx index da695201d6d76..1d36521d0b6f4 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx @@ -208,6 +208,21 @@ export const getPushedInfo = ( }; }; +export const getProfileUids = (userActions: CaseUserActions[]) => { + const uids = userActions.reduce<Set<string>>((acc, userAction) => { + if (userAction.type === ActionTypes.assignees) { + const uidsFromPayload = userAction.payload.assignees.map((assignee) => assignee.uid); + for (const uid of uidsFromPayload) { + acc.add(uid); + } + } + + return acc; + }, new Set()); + + return uids; +}; + export const useGetCaseUserActions = (caseId: string, caseConnectorId: string) => { const toasts = useToasts(); const abortCtrlRef = new AbortController(); @@ -221,9 +236,12 @@ export const useGetCaseUserActions = (caseId: string, caseConnectorId: string) = const caseUserActions = !isEmpty(response) ? response : []; const pushedInfo = getPushedInfo(caseUserActions, caseConnectorId); + const profileUids = getProfileUids(caseUserActions); + return { caseUserActions, participants, + profileUids, ...pushedInfo, }; }, diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.tsx index ce19e68fa1798..7b046cac3f13f 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.tsx @@ -19,6 +19,7 @@ export const DEFAULT_FILTER_OPTIONS: FilterOptions = { search: '', searchFields: DEFAULT_SEARCH_FIELDS, severity: SeverityAll, + assignees: [], reporters: [], status: StatusAll, tags: [], diff --git a/x-pack/plugins/cases/public/containers/use_get_cases_metrics.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases_metrics.test.tsx index 6601a104d9f7d..a8747a2bd43a5 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases_metrics.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases_metrics.test.tsx @@ -15,7 +15,7 @@ import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; jest.mock('../api'); jest.mock('../common/lib/kibana'); -describe('useGetReporters', () => { +describe('useGetCasesMetrics', () => { beforeEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); diff --git a/x-pack/plugins/cases/public/containers/use_get_reporters.test.tsx b/x-pack/plugins/cases/public/containers/use_get_reporters.test.tsx deleted file mode 100644 index 38d47d3aa9cbb..0000000000000 --- a/x-pack/plugins/cases/public/containers/use_get_reporters.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * 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 from 'react'; -import { renderHook, act } from '@testing-library/react-hooks'; -import { useGetReporters, UseGetReporters } from './use_get_reporters'; -import { reporters, respReporters } from './mock'; -import * as api from './api'; -import { TestProviders } from '../common/mock'; -import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; - -jest.mock('./api'); -jest.mock('../common/lib/kibana'); - -describe('useGetReporters', () => { - const abortCtrl = new AbortController(); - beforeEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - - it('init', async () => { - const { result } = renderHook<string, UseGetReporters>(() => useGetReporters(), { - wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, - }); - - await act(async () => { - expect(result.current).toEqual({ - reporters: [], - respReporters: [], - isLoading: true, - isError: false, - fetchReporters: result.current.fetchReporters, - }); - }); - }); - - it('calls getReporters api', async () => { - const spyOnGetReporters = jest.spyOn(api, 'getReporters'); - await act(async () => { - const { waitForNextUpdate } = renderHook<string, UseGetReporters>(() => useGetReporters(), { - wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, - }); - await waitForNextUpdate(); - expect(spyOnGetReporters).toBeCalledWith(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]); - }); - }); - - it('fetch reporters', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook<string, UseGetReporters>( - () => useGetReporters(), - { - wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, - } - ); - await waitForNextUpdate(); - expect(result.current).toEqual({ - reporters, - respReporters, - isLoading: false, - isError: false, - fetchReporters: result.current.fetchReporters, - }); - }); - }); - - it('refetch reporters', async () => { - const spyOnGetReporters = jest.spyOn(api, 'getReporters'); - await act(async () => { - const { result, waitForNextUpdate } = renderHook<string, UseGetReporters>( - () => useGetReporters(), - { - wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, - } - ); - await waitForNextUpdate(); - result.current.fetchReporters(); - expect(spyOnGetReporters).toHaveBeenCalledTimes(2); - }); - }); - - it('unhappy path', async () => { - const spyOnGetReporters = jest.spyOn(api, 'getReporters'); - spyOnGetReporters.mockImplementation(() => { - throw new Error('Something went wrong'); - }); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook<string, UseGetReporters>( - () => useGetReporters(), - { - wrapper: ({ children }) => <TestProviders>{children}</TestProviders>, - } - ); - await waitForNextUpdate(); - - expect(result.current).toEqual({ - reporters: [], - respReporters: [], - isLoading: false, - isError: true, - fetchReporters: result.current.fetchReporters, - }); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/containers/use_get_reporters.tsx b/x-pack/plugins/cases/public/containers/use_get_reporters.tsx deleted file mode 100644 index ce8aa4b961c23..0000000000000 --- a/x-pack/plugins/cases/public/containers/use_get_reporters.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/* - * 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 { useCallback, useEffect, useState, useRef } from 'react'; -import { isEmpty } from 'lodash/fp'; - -import { User } from '../../common/api'; -import { getReporters } from './api'; -import * as i18n from './translations'; -import { useToasts } from '../common/lib/kibana'; -import { useCasesContext } from '../components/cases_context/use_cases_context'; - -interface ReportersState { - reporters: string[]; - respReporters: User[]; - isLoading: boolean; - isError: boolean; -} - -const initialData: ReportersState = { - reporters: [], - respReporters: [], - isLoading: true, - isError: false, -}; - -export interface UseGetReporters extends ReportersState { - fetchReporters: () => void; -} - -export const useGetReporters = (): UseGetReporters => { - const { owner } = useCasesContext(); - const [reportersState, setReporterState] = useState<ReportersState>(initialData); - - const toasts = useToasts(); - const isCancelledRef = useRef(false); - const abortCtrlRef = useRef(new AbortController()); - - const fetchReporters = useCallback(async () => { - try { - isCancelledRef.current = false; - abortCtrlRef.current.abort(); - abortCtrlRef.current = new AbortController(); - setReporterState({ - ...reportersState, - isLoading: true, - }); - - const response = await getReporters(abortCtrlRef.current.signal, owner); - const myReporters = response - .map((r) => (r.full_name == null || isEmpty(r.full_name) ? r.username ?? '' : r.full_name)) - .filter((u) => !isEmpty(u)); - - if (!isCancelledRef.current) { - setReporterState({ - reporters: myReporters, - respReporters: response, - isLoading: false, - isError: false, - }); - } - } catch (error) { - if (!isCancelledRef.current) { - if (error.name !== 'AbortError') { - toasts.addError( - error.body && error.body.message ? new Error(error.body.message) : error, - { title: i18n.ERROR_TITLE } - ); - } - - setReporterState({ - reporters: [], - respReporters: [], - isLoading: false, - isError: true, - }); - } - } - }, [owner, reportersState, toasts]); - - useEffect(() => { - fetchReporters(); - return () => { - isCancelledRef.current = true; - abortCtrlRef.current.abort(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return { ...reportersState, fetchReporters }; -}; diff --git a/x-pack/plugins/cases/public/containers/user_profiles/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/user_profiles/__mocks__/api.ts index 36c88451124ca..6901852a405fa 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/__mocks__/api.ts @@ -8,8 +8,8 @@ import { UserProfile } from '@kbn/security-plugin/common'; import { userProfiles } from '../api.mock'; -export const suggestUserProfiles = async (): Promise<UserProfile[]> => - Promise.resolve(userProfiles); +export const suggestUserProfiles = async (): Promise<UserProfile[]> => userProfiles; -export const bulkGetUserProfiles = async (): Promise<UserProfile[]> => - Promise.resolve(userProfiles); +export const bulkGetUserProfiles = async (): Promise<UserProfile[]> => userProfiles; + +export const getCurrentUserProfile = async (): Promise<UserProfile> => userProfiles[0]; diff --git a/x-pack/plugins/cases/public/containers/user_profiles/api.mock.ts b/x-pack/plugins/cases/public/containers/user_profiles/api.mock.ts index e9382f7092ae0..1296cf9878827 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/api.mock.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/api.mock.ts @@ -41,3 +41,5 @@ export const userProfiles: UserProfile[] = [ ]; export const userProfilesIds = userProfiles.map((profile) => profile.uid); + +export const userProfilesMap = new Map(userProfiles.map((profile) => [profile.uid, profile])); diff --git a/x-pack/plugins/cases/public/containers/user_profiles/api.test.ts b/x-pack/plugins/cases/public/containers/user_profiles/api.test.ts index 7234cc9fb54fe..0f7c9d9c31fa9 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/api.test.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/api.test.ts @@ -5,9 +5,11 @@ * 2.0. */ +import { securityMock } from '@kbn/security-plugin/public/mocks'; +import { SecurityPluginStart } from '@kbn/security-plugin/public'; import { GENERAL_CASES_OWNER } from '../../../common/constants'; import { createStartServicesMock } from '../../common/lib/kibana/kibana_react.mock'; -import { bulkGetUserProfiles, suggestUserProfiles } from './api'; +import { bulkGetUserProfiles, getCurrentUserProfile, suggestUserProfiles } from './api'; import { userProfiles, userProfilesIds } from './api.mock'; describe('User profiles API', () => { @@ -24,7 +26,7 @@ describe('User profiles API', () => { const res = await suggestUserProfiles({ http, name: 'elastic', - owner: [GENERAL_CASES_OWNER], + owners: [GENERAL_CASES_OWNER], signal: abortCtrl.signal, }); @@ -35,22 +37,23 @@ describe('User profiles API', () => { await suggestUserProfiles({ http, name: 'elastic', - owner: [GENERAL_CASES_OWNER], + owners: [GENERAL_CASES_OWNER], signal: abortCtrl.signal, }); expect(http.post).toHaveBeenCalledWith('/internal/cases/_suggest_user_profiles', { - body: '{"name":"elastic","size":10,"owner":["cases"]}', + body: '{"name":"elastic","size":10,"owners":["cases"]}', signal: abortCtrl.signal, }); }); }); describe('bulkGetUserProfiles', () => { - const { security } = createStartServicesMock(); + let security: SecurityPluginStart; beforeEach(() => { jest.clearAllMocks(); + security = securityMock.createStart(); security.userProfiles.bulkGet = jest.fn().mockResolvedValue(userProfiles); }); @@ -63,7 +66,7 @@ describe('User profiles API', () => { expect(res).toEqual(userProfiles); }); - it('calls http.post correctly', async () => { + it('calls bulkGet correctly', async () => { await bulkGetUserProfiles({ security, uids: userProfilesIds, @@ -79,4 +82,34 @@ describe('User profiles API', () => { }); }); }); + + describe('getCurrentUserProfile', () => { + let security: SecurityPluginStart; + + const currentProfile = userProfiles[0]; + + beforeEach(() => { + jest.clearAllMocks(); + security = securityMock.createStart(); + security.userProfiles.getCurrent = jest.fn().mockResolvedValue(currentProfile); + }); + + it('returns the current user profile correctly', async () => { + const res = await getCurrentUserProfile({ + security, + }); + + expect(res).toEqual(currentProfile); + }); + + it('calls getCurrent correctly', async () => { + await getCurrentUserProfile({ + security, + }); + + expect(security.userProfiles.getCurrent).toHaveBeenCalledWith({ + dataPath: 'avatar', + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/containers/user_profiles/api.ts b/x-pack/plugins/cases/public/containers/user_profiles/api.ts index 6da84d1991423..cfd1c04d0afbc 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/api.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/api.ts @@ -13,7 +13,7 @@ import { INTERNAL_SUGGEST_USER_PROFILES_URL, DEFAULT_USER_SIZE } from '../../../ export interface SuggestUserProfilesArgs { http: HttpStart; name: string; - owner: string[]; + owners: string[]; signal: AbortSignal; size?: number; } @@ -22,11 +22,11 @@ export const suggestUserProfiles = async ({ http, name, size = DEFAULT_USER_SIZE, - owner, + owners, signal, }: SuggestUserProfilesArgs): Promise<UserProfile[]> => { const response = await http.post<UserProfile[]>(INTERNAL_SUGGEST_USER_PROFILES_URL, { - body: JSON.stringify({ name, size, owner }), + body: JSON.stringify({ name, size, owners }), signal, }); @@ -42,5 +42,19 @@ export const bulkGetUserProfiles = async ({ security, uids, }: BulkGetUserProfilesArgs): Promise<UserProfile[]> => { + if (uids.length === 0) { + return []; + } + return security.userProfiles.bulkGet({ uids: new Set(uids), dataPath: 'avatar' }); }; + +export interface GetCurrentUserProfileArgs { + security: SecurityPluginStart; +} + +export const getCurrentUserProfile = async ({ + security, +}: GetCurrentUserProfileArgs): Promise<UserProfile> => { + return security.userProfiles.getCurrent({ dataPath: 'avatar' }); +}; diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_assignees.test.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_assignees.test.ts new file mode 100644 index 0000000000000..db4527ae31e43 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_assignees.test.ts @@ -0,0 +1,113 @@ +/* + * 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 { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { renderHook } from '@testing-library/react-hooks'; +import { userProfiles, userProfilesMap } from './api.mock'; +import { useAssignees } from './use_assignees'; + +describe('useAssignees', () => { + it('returns an empty array when the caseAssignees is empty', () => { + const { result } = renderHook(() => + useAssignees({ caseAssignees: [], userProfiles: new Map(), currentUserProfile: undefined }) + ); + + expect(result.current.allAssignees).toHaveLength(0); + expect(result.current.assigneesWithProfiles).toHaveLength(0); + expect(result.current.assigneesWithoutProfiles).toHaveLength(0); + }); + + it('returns all items in the with profiles array when they have profiles', () => { + const { result } = renderHook(() => + useAssignees({ + caseAssignees: userProfiles.map((profile) => ({ uid: profile.uid })), + userProfiles: userProfilesMap, + currentUserProfile: undefined, + }) + ); + + expect(result.current.assigneesWithoutProfiles).toHaveLength(0); + expect(result.current.allAssignees).toEqual(result.current.assigneesWithProfiles); + expect(result.current.allAssignees).toEqual(userProfiles.map(asAssigneeWithProfile)); + }); + + it('returns a sorted list of assignees with profiles', () => { + const unsorted = [...userProfiles].reverse(); + const { result } = renderHook(() => + useAssignees({ + caseAssignees: unsorted.map((profile) => ({ uid: profile.uid })), + userProfiles: userProfilesMap, + currentUserProfile: undefined, + }) + ); + + expect(result.current.assigneesWithoutProfiles).toHaveLength(0); + expect(result.current.allAssignees).toEqual(result.current.assigneesWithProfiles); + expect(result.current.allAssignees).toEqual(userProfiles.map(asAssigneeWithProfile)); + }); + + it('returns all items in the without profiles array when they do not have profiles', () => { + const unknownProfiles = [{ uid: '1' }, { uid: '2' }]; + const { result } = renderHook(() => + useAssignees({ + caseAssignees: unknownProfiles, + userProfiles: userProfilesMap, + currentUserProfile: undefined, + }) + ); + + expect(result.current.assigneesWithoutProfiles).toHaveLength(2); + expect(result.current.assigneesWithoutProfiles).toEqual(unknownProfiles); + expect(result.current.allAssignees).toEqual(unknownProfiles); + }); + + it('returns 1 user with a valid profile and 1 user with no profile and combines them in the all field', () => { + const assignees = [{ uid: '1' }, { uid: userProfiles[0].uid }]; + const { result } = renderHook(() => + useAssignees({ + caseAssignees: assignees, + userProfiles: userProfilesMap, + currentUserProfile: undefined, + }) + ); + + expect(result.current.assigneesWithProfiles).toHaveLength(1); + expect(result.current.assigneesWithoutProfiles).toHaveLength(1); + expect(result.current.allAssignees).toHaveLength(2); + + expect(result.current.assigneesWithProfiles).toEqual([asAssigneeWithProfile(userProfiles[0])]); + expect(result.current.assigneesWithoutProfiles).toEqual([{ uid: '1' }]); + expect(result.current.allAssignees).toEqual([ + asAssigneeWithProfile(userProfiles[0]), + { uid: '1' }, + ]); + }); + + it('returns assignees with profiles with the current user at the front', () => { + const { result } = renderHook(() => + useAssignees({ + caseAssignees: userProfiles, + userProfiles: userProfilesMap, + currentUserProfile: userProfiles[2], + }) + ); + + expect(result.current.assigneesWithProfiles).toHaveLength(3); + expect(result.current.allAssignees).toHaveLength(3); + + const asAssignees = userProfiles.map(asAssigneeWithProfile); + + expect(result.current.assigneesWithProfiles).toEqual([ + asAssignees[2], + asAssignees[0], + asAssignees[1], + ]); + expect(result.current.allAssignees).toEqual([asAssignees[2], asAssignees[0], asAssignees[1]]); + }); +}); + +const asAssigneeWithProfile = (profile: UserProfileWithAvatar) => ({ uid: profile.uid, profile }); diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_assignees.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_assignees.ts new file mode 100644 index 0000000000000..2e1bb0a61dbda --- /dev/null +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_assignees.ts @@ -0,0 +1,68 @@ +/* + * 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 { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { useMemo } from 'react'; +import { CaseAssignees } from '../../../common/api'; +import { CurrentUserProfile } from '../../components/types'; +import { bringCurrentUserToFrontAndSort } from '../../components/user_profiles/sort'; +import { Assignee, AssigneeWithProfile } from '../../components/user_profiles/types'; + +interface PartitionedAssignees { + usersWithProfiles: UserProfileWithAvatar[]; + usersWithoutProfiles: Assignee[]; +} + +export const useAssignees = ({ + caseAssignees, + userProfiles, + currentUserProfile, +}: { + caseAssignees: CaseAssignees; + userProfiles: Map<string, UserProfileWithAvatar>; + currentUserProfile: CurrentUserProfile; +}): { + assigneesWithProfiles: AssigneeWithProfile[]; + assigneesWithoutProfiles: Assignee[]; + allAssignees: Assignee[]; +} => { + const { assigneesWithProfiles, assigneesWithoutProfiles } = useMemo(() => { + const { usersWithProfiles, usersWithoutProfiles } = caseAssignees.reduce<PartitionedAssignees>( + (acc, assignee) => { + const profile = userProfiles.get(assignee.uid); + + if (profile) { + acc.usersWithProfiles.push(profile); + } else { + acc.usersWithoutProfiles.push({ uid: assignee.uid }); + } + + return acc; + }, + { usersWithProfiles: [], usersWithoutProfiles: [] } + ); + + const orderedProf = bringCurrentUserToFrontAndSort(currentUserProfile, usersWithProfiles); + + const assigneesWithProfile2 = orderedProf?.map((profile) => ({ uid: profile.uid, profile })); + return { + assigneesWithProfiles: assigneesWithProfile2 ?? [], + assigneesWithoutProfiles: usersWithoutProfiles, + }; + }, [caseAssignees, currentUserProfile, userProfiles]); + + const allAssignees = useMemo( + () => [...assigneesWithProfiles, ...assigneesWithoutProfiles], + [assigneesWithProfiles, assigneesWithoutProfiles] + ); + + return { + assigneesWithProfiles, + assigneesWithoutProfiles, + allAssignees, + }; +}; diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts index 7591bf394d5c1..af0482f41b25a 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.test.ts @@ -6,15 +6,18 @@ */ import { renderHook } from '@testing-library/react-hooks'; -import { useToasts } from '../../common/lib/kibana'; +import { useToasts, useKibana } from '../../common/lib/kibana'; import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; import * as api from './api'; import { useBulkGetUserProfiles } from './use_bulk_get_user_profiles'; import { userProfilesIds } from './api.mock'; +import { createStartServicesMock } from '../../common/lib/kibana/kibana_react.mock'; jest.mock('../../common/lib/kibana'); jest.mock('./api'); +const useKibanaMock = useKibana as jest.Mock; + describe('useBulkGetUserProfiles', () => { const props = { uids: userProfilesIds, @@ -28,10 +31,13 @@ describe('useBulkGetUserProfiles', () => { beforeEach(() => { appMockRender = createAppMockRenderer(); jest.clearAllMocks(); + useKibanaMock.mockReturnValue({ + services: { ...createStartServicesMock() }, + }); }); it('calls bulkGetUserProfiles with correct arguments', async () => { - const spyOnSuggestUserProfiles = jest.spyOn(api, 'bulkGetUserProfiles'); + const spyOnBulkGetUserProfiles = jest.spyOn(api, 'bulkGetUserProfiles'); const { result, waitFor } = renderHook(() => useBulkGetUserProfiles(props), { wrapper: appMockRender.AppWrapper, @@ -39,16 +45,59 @@ describe('useBulkGetUserProfiles', () => { await waitFor(() => result.current.isSuccess); - expect(spyOnSuggestUserProfiles).toBeCalledWith({ + expect(spyOnBulkGetUserProfiles).toBeCalledWith({ ...props, security: expect.anything(), }); }); + it('returns a mapping with user profiles', async () => { + const { result, waitFor } = renderHook(() => useBulkGetUserProfiles(props), { + wrapper: appMockRender.AppWrapper, + }); + + await waitFor(() => result.current.isSuccess); + + expect(result.current.data).toMatchInlineSnapshot(` + Map { + "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0" => Object { + "data": Object {}, + "enabled": true, + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "user": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "username": "damaged_raccoon", + }, + }, + "u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0" => Object { + "data": Object {}, + "enabled": true, + "uid": "u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0", + "user": Object { + "email": "physical_dinosaur@elastic.co", + "full_name": "Physical Dinosaur", + "username": "physical_dinosaur", + }, + }, + "u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0" => Object { + "data": Object {}, + "enabled": true, + "uid": "u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0", + "user": Object { + "email": "wet_dingo@elastic.co", + "full_name": "Wet Dingo", + "username": "wet_dingo", + }, + }, + } + `); + }); + it('shows a toast error message when an error occurs in the response', async () => { - const spyOnSuggestUserProfiles = jest.spyOn(api, 'bulkGetUserProfiles'); + const spyOnBulkGetUserProfiles = jest.spyOn(api, 'bulkGetUserProfiles'); - spyOnSuggestUserProfiles.mockImplementation(() => { + spyOnBulkGetUserProfiles.mockImplementation(() => { throw new Error('Something went wrong'); }); diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts index 78c310462f77e..de180b5970f3b 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_bulk_get_user_profiles.ts @@ -6,24 +6,33 @@ */ import { useQuery, UseQueryResult } from '@tanstack/react-query'; -import { UserProfile } from '@kbn/security-plugin/common'; +import { UserProfileWithAvatar } from '@kbn/user-profile-components'; import * as i18n from '../translations'; import { useKibana, useToasts } from '../../common/lib/kibana'; import { ServerError } from '../../types'; import { USER_PROFILES_CACHE_KEY, USER_PROFILES_BULK_GET_CACHE_KEY } from '../constants'; import { bulkGetUserProfiles } from './api'; +const profilesToMap = (profiles: UserProfileWithAvatar[]): Map<string, UserProfileWithAvatar> => + profiles.reduce<Map<string, UserProfileWithAvatar>>((acc, profile) => { + acc.set(profile.uid, profile); + return acc; + }, new Map<string, UserProfileWithAvatar>()); + export const useBulkGetUserProfiles = ({ uids }: { uids: string[] }) => { const { security } = useKibana().services; const toasts = useToasts(); - return useQuery<UserProfile[], ServerError>( + return useQuery<UserProfileWithAvatar[], ServerError, Map<string, UserProfileWithAvatar>>( [USER_PROFILES_CACHE_KEY, USER_PROFILES_BULK_GET_CACHE_KEY, uids], () => { return bulkGetUserProfiles({ security, uids }); }, { + select: profilesToMap, + retry: false, + keepPreviousData: true, onError: (error: ServerError) => { if (error.name !== 'AbortError') { toasts.addError( @@ -38,4 +47,7 @@ export const useBulkGetUserProfiles = ({ uids }: { uids: string[] }) => { ); }; -export type UseSuggestUserProfiles = UseQueryResult<UserProfile[], ServerError>; +export type UseBulkGetUserProfiles = UseQueryResult< + Map<string, UserProfileWithAvatar>, + ServerError +>; diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_get_current_user_profile.test.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_get_current_user_profile.test.ts new file mode 100644 index 0000000000000..ebc896a480cb0 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_get_current_user_profile.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useToasts, useKibana } from '../../common/lib/kibana'; +import { createStartServicesMock } from '../../common/lib/kibana/kibana_react.mock'; +import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; +import * as api from './api'; +import { useGetCurrentUserProfile } from './use_get_current_user_profile'; + +jest.mock('../../common/lib/kibana'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mock; + +describe('useGetCurrentUserProfile', () => { + const addSuccess = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addSuccess, addError: jest.fn() }); + + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + useKibanaMock.mockReturnValue({ + services: { ...createStartServicesMock() }, + }); + }); + + it('calls getCurrentUserProfile with correct arguments', async () => { + const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); + + const { result, waitFor } = renderHook(() => useGetCurrentUserProfile(), { + wrapper: appMockRender.AppWrapper, + }); + + await waitFor(() => result.current.isSuccess); + + expect(spyOnGetCurrentUserProfile).toBeCalledWith({ + security: expect.anything(), + }); + }); + + it('shows a toast error message when an error occurs in the response', async () => { + const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); + + spyOnGetCurrentUserProfile.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + const addError = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addSuccess, addError }); + + const { result, waitFor } = renderHook(() => useGetCurrentUserProfile(), { + wrapper: appMockRender.AppWrapper, + }); + + await waitFor(() => result.current.isError); + + expect(addError).toHaveBeenCalled(); + }); + + it('does not show a toast error message when a 404 error is returned', async () => { + const spyOnGetCurrentUserProfile = jest.spyOn(api, 'getCurrentUserProfile'); + + spyOnGetCurrentUserProfile.mockImplementation(() => { + throw new MockServerError('profile not found', 404); + }); + + const addError = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addSuccess, addError }); + + const { result, waitFor } = renderHook(() => useGetCurrentUserProfile(), { + wrapper: appMockRender.AppWrapper, + }); + + await waitFor(() => result.current.isError); + + expect(addError).not.toHaveBeenCalled(); + }); +}); + +class MockServerError extends Error { + public readonly body: { + statusCode: number; + }; + + constructor(message?: string, statusCode: number = 200) { + super(message); + this.name = this.constructor.name; + this.body = { statusCode }; + } +} diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_get_current_user_profile.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_get_current_user_profile.ts new file mode 100644 index 0000000000000..37c29fa0b2d01 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_get_current_user_profile.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import { UserProfile } from '@kbn/security-plugin/common'; +import * as i18n from '../translations'; +import { useKibana, useToasts } from '../../common/lib/kibana'; +import { ServerError } from '../../types'; +import { USER_PROFILES_CACHE_KEY, USER_PROFILES_GET_CURRENT_CACHE_KEY } from '../constants'; +import { getCurrentUserProfile } from './api'; + +export const useGetCurrentUserProfile = () => { + const { security } = useKibana().services; + + const toasts = useToasts(); + + return useQuery<UserProfile, ServerError>( + [USER_PROFILES_CACHE_KEY, USER_PROFILES_GET_CURRENT_CACHE_KEY], + () => { + return getCurrentUserProfile({ security }); + }, + { + retry: false, + onError: (error: ServerError) => { + // Anonymous users (users authenticated via a proxy or configured in the kibana config) will result in a 404 + // from the security plugin. If this happens we'll silence the error and operate without the current user profile + if (error.name !== 'AbortError' && error.body?.statusCode !== 404) { + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { + title: i18n.ERROR_TITLE, + } + ); + } + }, + } + ); +}; + +export type UseGetCurrentUserProfile = UseQueryResult<UserProfile, ServerError>; diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_suggest_user_profiles.test.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_suggest_user_profiles.test.ts index ef5fe32a23dff..2d4482b94a9c6 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/use_suggest_user_profiles.test.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_suggest_user_profiles.test.ts @@ -18,7 +18,7 @@ jest.mock('./api'); describe('useSuggestUserProfiles', () => { const props = { name: 'elastic', - owner: [GENERAL_CASES_OWNER], + owners: [GENERAL_CASES_OWNER], }; const addSuccess = jest.fn(); diff --git a/x-pack/plugins/cases/public/containers/user_profiles/use_suggest_user_profiles.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_suggest_user_profiles.ts index 6c83f853b2624..26e03d0163c8e 100644 --- a/x-pack/plugins/cases/public/containers/user_profiles/use_suggest_user_profiles.ts +++ b/x-pack/plugins/cases/public/containers/user_profiles/use_suggest_user_profiles.ts @@ -9,24 +9,42 @@ import { useState } from 'react'; import { useQuery, UseQueryResult } from '@tanstack/react-query'; import useDebounce from 'react-use/lib/useDebounce'; import { UserProfile } from '@kbn/security-plugin/common'; -import { DEFAULT_USER_SIZE } from '../../../common/constants'; +import { noop } from 'lodash'; +import { DEFAULT_USER_SIZE, SEARCH_DEBOUNCE_MS } from '../../../common/constants'; import * as i18n from '../translations'; import { useKibana, useToasts } from '../../common/lib/kibana'; import { ServerError } from '../../types'; import { USER_PROFILES_CACHE_KEY, USER_PROFILES_SUGGEST_CACHE_KEY } from '../constants'; import { suggestUserProfiles, SuggestUserProfilesArgs } from './api'; -const DEBOUNCE_MS = 500; +type Props = Omit<SuggestUserProfilesArgs, 'signal' | 'http'> & { onDebounce?: () => void }; + +/** + * Time in ms until the data become stale. + * We set the stale time to one minute + * to prevent fetching the same queries + * while the user is typing. + */ + +const STALE_TIME = 1000 * 60; export const useSuggestUserProfiles = ({ name, - owner, + owners, size = DEFAULT_USER_SIZE, -}: Omit<SuggestUserProfilesArgs, 'signal' | 'http'>) => { + onDebounce = noop, +}: Props) => { const { http } = useKibana().services; const [debouncedName, setDebouncedName] = useState(name); - useDebounce(() => setDebouncedName(name), DEBOUNCE_MS, [name]); + useDebounce( + () => { + setDebouncedName(name); + onDebounce(); + }, + SEARCH_DEBOUNCE_MS, + [name] + ); const toasts = useToasts(); @@ -34,20 +52,22 @@ export const useSuggestUserProfiles = ({ [ USER_PROFILES_CACHE_KEY, USER_PROFILES_SUGGEST_CACHE_KEY, - { name: debouncedName, owner, size }, + { name: debouncedName, owners, size }, ], () => { const abortCtrlRef = new AbortController(); return suggestUserProfiles({ http, name: debouncedName, - owner, + owners, size, signal: abortCtrlRef.signal, }); }, { retry: false, + keepPreviousData: true, + staleTime: STALE_TIME, onError: (error: ServerError) => { if (error.name !== 'AbortError') { toasts.addError( diff --git a/x-pack/plugins/cases/public/utils/permissions.test.ts b/x-pack/plugins/cases/public/utils/permissions.test.ts new file mode 100644 index 0000000000000..66e63e6950dd4 --- /dev/null +++ b/x-pack/plugins/cases/public/utils/permissions.test.ts @@ -0,0 +1,37 @@ +/* + * 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 { noCasesPermissions, readCasesPermissions } from '../common/mock'; +import { getAllPermissionsExceptFrom, isReadOnlyPermissions } from './permissions'; + +describe('permissions', () => { + describe('isReadOnlyPermissions', () => { + const tests = [['update'], ['create'], ['delete'], ['push'], ['all']]; + + it('returns true if the user has only read permissions', async () => { + expect(isReadOnlyPermissions(readCasesPermissions())).toBe(true); + }); + + it('returns true if the user has not read permissions', async () => { + expect(isReadOnlyPermissions(noCasesPermissions())).toBe(false); + }); + + it.each(tests)( + 'returns false if the user has permission %s=true and read=true', + async (permission) => { + const noPermissions = noCasesPermissions(); + expect(isReadOnlyPermissions({ ...noPermissions, [permission]: true })).toBe(false); + } + ); + }); + + describe('getAllPermissionsExceptFrom', () => { + it('returns the correct permissions', async () => { + expect(getAllPermissionsExceptFrom('create')).toEqual(['read', 'update', 'delete', 'push']); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/utils/permissions.ts b/x-pack/plugins/cases/public/utils/permissions.ts index 827535d484588..75e15f8859e58 100644 --- a/x-pack/plugins/cases/public/utils/permissions.ts +++ b/x-pack/plugins/cases/public/utils/permissions.ts @@ -17,3 +17,10 @@ export const isReadOnlyPermissions = (permissions: CasesPermissions) => { permissions.read ); }; + +type CasePermission = Exclude<keyof CasesPermissions, 'all'>; + +export const allCasePermissions: CasePermission[] = ['create', 'read', 'update', 'delete', 'push']; + +export const getAllPermissionsExceptFrom = (capToExclude: CasePermission): CasePermission[] => + allCasePermissions.filter((permission) => permission !== capToExclude) as CasePermission[]; diff --git a/x-pack/plugins/cases/server/features.ts b/x-pack/plugins/cases/server/features.ts index d2ddc6a1030a0..9f92c7d9398a6 100644 --- a/x-pack/plugins/cases/server/features.ts +++ b/x-pack/plugins/cases/server/features.ts @@ -55,7 +55,7 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { ui: capabilities.all, }, read: { - api: ['bulkGetUserProfiles'], + api: ['casesSuggestUserProfiles', 'bulkGetUserProfiles'], cases: { read: [APP_ID], }, diff --git a/x-pack/plugins/cases/server/services/user_profiles/index.ts b/x-pack/plugins/cases/server/services/user_profiles/index.ts index 36bc0c439a79e..4ba9eb630e53f 100644 --- a/x-pack/plugins/cases/server/services/user_profiles/index.ts +++ b/x-pack/plugins/cases/server/services/user_profiles/index.ts @@ -19,8 +19,8 @@ import { excess, SuggestUserProfilesRequestRt, throwErrors } from '../../../comm import { Operations } from '../../authorization'; import { createCaseError } from '../../common/error'; -const MAX_SUGGESTION_SIZE = 100; -const MIN_SUGGESTION_SIZE = 0; +const MAX_PROFILES_SIZE = 100; +const MIN_PROFILES_SIZE = 0; interface UserProfileOptions { securityPluginSetup?: SecurityPluginSetup; @@ -41,6 +41,32 @@ export class UserProfileService { this.options = options; } + private static suggestUsers({ + securityPluginStart, + spaceId, + searchTerm, + size, + owners, + }: { + securityPluginStart: SecurityPluginStart; + spaceId: string; + searchTerm: string; + size?: number; + owners: string[]; + }) { + return securityPluginStart.userProfiles.suggest({ + name: searchTerm, + size, + dataPath: 'avatar', + requiredPrivileges: { + spaceId, + privileges: { + kibana: UserProfileService.buildRequiredPrivileges(owners, securityPluginStart), + }, + }, + }); + } + public async suggest(request: KibanaRequest): Promise<UserProfile[]> { const params = pipe( excess(SuggestUserProfilesRequestRt).decode(request.body), @@ -61,29 +87,20 @@ export class UserProfileService { securityPluginStart: this.options.securityPluginStart, }; - /** - * The limit of 100 helps prevent DDoS attacks and is also enforced by the security plugin. - */ - if (size !== undefined && (size > MAX_SUGGESTION_SIZE || size < MIN_SUGGESTION_SIZE)) { - throw Boom.badRequest('size must be between 0 and 100'); - } + UserProfileService.validateSizeParam(size); - if (!UserProfileService.isSecurityEnabled(securityPluginFields)) { + if (!UserProfileService.isSecurityEnabled(securityPluginFields) || owners.length <= 0) { return []; } const { securityPluginStart } = securityPluginFields; - return securityPluginStart.userProfiles.suggest({ - name, + return UserProfileService.suggestUsers({ + searchTerm: name, size, - dataPath: 'avatar', - requiredPrivileges: { - spaceId: spaces.spacesService.getSpaceId(request), - privileges: { - kibana: UserProfileService.buildRequiredPrivileges(owners, securityPluginStart), - }, - }, + owners, + securityPluginStart, + spaceId: spaces.spacesService.getSpaceId(request), }); } catch (error) { throw createCaseError({ @@ -94,6 +111,15 @@ export class UserProfileService { } } + private static validateSizeParam(size?: number) { + /** + * The limit of 100 helps prevent DDoS attacks and is also enforced by the security plugin. + */ + if (size !== undefined && (size > MAX_PROFILES_SIZE || size < MIN_PROFILES_SIZE)) { + throw Boom.badRequest('size must be between 0 and 100'); + } + } + private static isSecurityEnabled(fields: { securityPluginSetup?: SecurityPluginSetup; securityPluginStart?: SecurityPluginStart; diff --git a/x-pack/plugins/ml/public/application/util/dependency_cache.ts b/x-pack/plugins/ml/public/application/util/dependency_cache.ts index 3680f8b63b0c9..0b78fb460ed85 100644 --- a/x-pack/plugins/ml/public/application/util/dependency_cache.ts +++ b/x-pack/plugins/ml/public/application/util/dependency_cache.ts @@ -24,7 +24,7 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/ import type { DashboardStart } from '@kbn/dashboard-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { DataViewsContract } from '@kbn/data-views-plugin/public'; -import type { SecurityPluginSetup } from '@kbn/security-plugin/public'; +import type { SecurityPluginStart } from '@kbn/security-plugin/public'; import type { MapsStartApi } from '@kbn/maps-plugin/public'; import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public'; @@ -43,7 +43,7 @@ export interface DependencyCache { savedObjectsClient: SavedObjectsClientContract | null; application: ApplicationStart | null; http: HttpStart | null; - security: SecurityPluginSetup | undefined | null; + security: SecurityPluginStart | undefined | null; i18n: I18nStart | null; dashboard: DashboardStart | null; maps: MapsStartApi | null; diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 9d084708e6529..3037d84180349 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -30,7 +30,7 @@ import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/publ import type { LicenseManagementUIPluginSetup } from '@kbn/license-management-plugin/public'; import type { LicensingPluginSetup } from '@kbn/licensing-plugin/public'; -import type { SecurityPluginSetup } from '@kbn/security-plugin/public'; +import type { SecurityPluginStart } from '@kbn/security-plugin/public'; import type { MapsStartApi, MapsSetupApi } from '@kbn/maps-plugin/public'; import { @@ -66,10 +66,10 @@ export interface MlStartDependencies { charts: ChartsPluginStart; lens?: LensPublicStart; cases?: CasesUiStart; + security: SecurityPluginStart; } export interface MlSetupDependencies { - security?: SecurityPluginSetup; maps?: MapsSetupApi; licensing: LicensingPluginSetup; management?: ManagementSetup; @@ -119,7 +119,7 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> { unifiedSearch: pluginsStart.unifiedSearch, dashboard: pluginsStart.dashboard, share: pluginsStart.share, - security: pluginsSetup.security, + security: pluginsStart.security, licensing: pluginsSetup.licensing, management: pluginsSetup.management, licenseManagement: pluginsSetup.licenseManagement, diff --git a/x-pack/plugins/observability/server/plugin.ts b/x-pack/plugins/observability/server/plugin.ts index 4a1f91719bca6..1d9d3cbf455f1 100644 --- a/x-pack/plugins/observability/server/plugin.ts +++ b/x-pack/plugins/observability/server/plugin.ts @@ -74,7 +74,7 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> { ui: casesCapabilities.all, }, read: { - api: ['bulkGetUserProfiles'], + api: ['casesSuggestUserProfiles', 'bulkGetUserProfiles'], app: [casesFeatureId, 'kibana'], catalogue: [observabilityFeatureId], cases: { diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts index 0b53557dbcd03..81ae0d5b40fd7 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/creation.spec.ts @@ -17,8 +17,6 @@ import { ALL_CASES_OPEN_CASES_STATS, ALL_CASES_OPENED_ON, ALL_CASES_PAGE_TITLE, - ALL_CASES_REPORTER, - ALL_CASES_REPORTERS_COUNT, ALL_CASES_SERVICE_NOW_INCIDENT, ALL_CASES_TAGS, ALL_CASES_TAGS_COUNT, @@ -85,10 +83,8 @@ describe('Cases', () => { cy.get(ALL_CASES_CLOSED_CASES_STATS).should('have.text', '0'); cy.get(ALL_CASES_IN_PROGRESS_CASES_STATS).should('have.text', '0'); cy.get(ALL_CASES_OPEN_CASES_COUNT).should('have.text', 'Open (1)'); - cy.get(ALL_CASES_REPORTERS_COUNT).should('have.text', 'Reporter1'); cy.get(ALL_CASES_TAGS_COUNT).should('have.text', 'Tags2'); cy.get(ALL_CASES_NAME).should('have.text', this.mycase.name); - cy.get(ALL_CASES_REPORTER).should('have.text', 'e'); (this.mycase as TestCase).tags.forEach((tag) => { cy.get(ALL_CASES_TAGS(tag)).should('have.text', tag); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/all_cases.ts b/x-pack/plugins/security_solution/cypress/screens/all_cases.ts index 3f5bcb912ee44..8109bc31e07ad 100644 --- a/x-pack/plugins/security_solution/cypress/screens/all_cases.ts +++ b/x-pack/plugins/security_solution/cypress/screens/all_cases.ts @@ -34,11 +34,6 @@ export const ALL_CASES_OPENED_ON = '[data-test-subj="case-table-column-createdAt export const ALL_CASES_PAGE_TITLE = '[data-test-subj="header-page-title"]'; -export const ALL_CASES_REPORTER = '[data-test-subj="case-table-column-createdBy"]'; - -export const ALL_CASES_REPORTERS_COUNT = - '[data-test-subj="options-filter-popover-button-Reporter"]'; - export const ALL_CASES_SERVICE_NOW_INCIDENT = '[data-test-subj="case-table-column-external-notPushed"]'; diff --git a/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/cases/import_case.spec.ts b/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/cases/import_case.spec.ts index 13afc4c7889eb..4eecc36fe928a 100644 --- a/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/cases/import_case.spec.ts +++ b/x-pack/plugins/security_solution/cypress/upgrade_integration/threat_hunting/cases/import_case.spec.ts @@ -13,7 +13,6 @@ import { ALL_CASES_NOT_PUSHED, ALL_CASES_NUMBER_OF_ALERTS, ALL_CASES_OPEN_CASES_STATS, - ALL_CASES_REPORTER, ALL_CASES_IN_PROGRESS_STATUS, } from '../../../screens/all_cases'; import { @@ -108,7 +107,6 @@ describe('Import case after upgrade', () => { it('Displays the correct case details on the cases page', () => { cy.get(ALL_CASES_NAME).should('have.text', importedCase.title); - cy.get(ALL_CASES_REPORTER).should('have.text', importedCase.initial); cy.get(ALL_CASES_NUMBER_OF_ALERTS).should('have.text', importedCase.numberOfAlerts); cy.get(ALL_CASES_COMMENTS_COUNT).should('have.text', importedCase.numberOfComments); cy.get(ALL_CASES_NOT_PUSHED).should('be.visible'); diff --git a/x-pack/plugins/security_solution/server/features.ts b/x-pack/plugins/security_solution/server/features.ts index c962e9fbf2d87..2a794285b52b7 100644 --- a/x-pack/plugins/security_solution/server/features.ts +++ b/x-pack/plugins/security_solution/server/features.ts @@ -45,7 +45,7 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { ui: casesCapabilities.all, }, read: { - api: ['bulkGetUserProfiles'], + api: ['casesSuggestUserProfiles', 'bulkGetUserProfiles'], app: [CASES_FEATURE_ID, 'kibana'], catalogue: [APP_ID], cases: { diff --git a/x-pack/test/api_integration/apis/cases/bulk_get_user_profiles.ts b/x-pack/test/api_integration/apis/cases/bulk_get_user_profiles.ts index a8aad06c999d0..8de4b3dc32a0c 100644 --- a/x-pack/test/api_integration/apis/cases/bulk_get_user_profiles.ts +++ b/x-pack/test/api_integration/apis/cases/bulk_get_user_profiles.ts @@ -11,11 +11,11 @@ import { APP_ID as SECURITY_SOLUTION_APP_ID } from '@kbn/security-solution-plugi import { observabilityFeatureId as OBSERVABILITY_APP_ID } from '@kbn/observability-plugin/common'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { deleteAllCaseItems } from '../../../cases_api_integration/common/lib/utils'; import { - deleteAllCaseItems, + bulkGetUserProfiles, suggestUserProfiles, -} from '../../../cases_api_integration/common/lib/utils'; -import { bulkGetUserProfiles } from '../../../cases_api_integration/common/lib/user_profiles'; +} from '../../../cases_api_integration/common/lib/user_profiles'; import { casesAllUser, casesReadUser, diff --git a/x-pack/test/api_integration/apis/cases/index.ts b/x-pack/test/api_integration/apis/cases/index.ts index 3bb170937bafc..5b9d9d1bfe918 100644 --- a/x-pack/test/api_integration/apis/cases/index.ts +++ b/x-pack/test/api_integration/apis/cases/index.ts @@ -10,7 +10,7 @@ import { deleteUsersAndRoles, } from '../../../cases_api_integration/common/lib/authentication'; -import { loginUsers } from '../../../cases_api_integration/common/lib/utils'; +import { loginUsers } from '../../../cases_api_integration/common/lib/user_profiles'; import { casesAllUser, obsCasesAllUser, secAllUser, users } from './common/users'; import { roles } from './common/roles'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/cases/suggest_user_profiles.ts b/x-pack/test/api_integration/apis/cases/suggest_user_profiles.ts index 72680ef786e9e..dca999ab68902 100644 --- a/x-pack/test/api_integration/apis/cases/suggest_user_profiles.ts +++ b/x-pack/test/api_integration/apis/cases/suggest_user_profiles.ts @@ -11,15 +11,16 @@ import { APP_ID as SECURITY_SOLUTION_APP_ID } from '@kbn/security-solution-plugi import { observabilityFeatureId as OBSERVABILITY_APP_ID } from '@kbn/observability-plugin/common'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { - deleteAllCaseItems, - suggestUserProfiles, -} from '../../../cases_api_integration/common/lib/utils'; +import { deleteAllCaseItems } from '../../../cases_api_integration/common/lib/utils'; +import { suggestUserProfiles } from '../../../cases_api_integration/common/lib/user_profiles'; import { casesAllUser, casesOnlyDeleteUser, + casesReadUser, obsCasesAllUser, obsCasesOnlyDeleteUser, + obsCasesReadUser, + secAllCasesNoneUser, secAllCasesReadUser, secAllUser, } from './common/users'; @@ -33,27 +34,34 @@ export default ({ getService }: FtrProviderContext): void => { await deleteAllCaseItems(es); }); - for (const { user, owner } of [ - { user: secAllUser, owner: SECURITY_SOLUTION_APP_ID }, - { user: casesAllUser, owner: CASES_APP_ID }, - { user: obsCasesAllUser, owner: OBSERVABILITY_APP_ID }, + for (const { user, searchTerm, owner } of [ + { user: secAllUser, searchTerm: secAllUser.username, owner: SECURITY_SOLUTION_APP_ID }, + { + user: secAllCasesReadUser, + searchTerm: secAllUser.username, + owner: SECURITY_SOLUTION_APP_ID, + }, + { user: casesAllUser, searchTerm: casesAllUser.username, owner: CASES_APP_ID }, + { user: casesReadUser, searchTerm: casesAllUser.username, owner: CASES_APP_ID }, + { user: obsCasesAllUser, searchTerm: obsCasesAllUser.username, owner: OBSERVABILITY_APP_ID }, + { user: obsCasesReadUser, searchTerm: obsCasesAllUser.username, owner: OBSERVABILITY_APP_ID }, ]) { it(`User ${ user.username } with roles(s) ${user.roles.join()} can retrieve user profile suggestions`, async () => { const profiles = await suggestUserProfiles({ supertest: supertestWithoutAuth, - req: { name: user.username, owners: [owner], size: 1 }, + req: { name: searchTerm, owners: [owner], size: 1 }, auth: { user, space: null }, }); expect(profiles.length).to.be(1); - expect(profiles[0].user.username).to.eql(user.username); + expect(profiles[0].user.username).to.eql(searchTerm); }); } for (const { user, owner } of [ - { user: secAllCasesReadUser, owner: SECURITY_SOLUTION_APP_ID }, + { user: secAllCasesNoneUser, owner: SECURITY_SOLUTION_APP_ID }, { user: casesOnlyDeleteUser, owner: CASES_APP_ID }, { user: obsCasesOnlyDeleteUser, owner: OBSERVABILITY_APP_ID }, ]) { diff --git a/x-pack/test/cases_api_integration/common/fixtures/plugins/observability/server/plugin.ts b/x-pack/test/cases_api_integration/common/fixtures/plugins/observability/server/plugin.ts index 9dcdc8a26af0e..fb872f775c5bb 100644 --- a/x-pack/test/cases_api_integration/common/fixtures/plugins/observability/server/plugin.ts +++ b/x-pack/test/cases_api_integration/common/fixtures/plugins/observability/server/plugin.ts @@ -31,7 +31,7 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu cases: ['observabilityFixture'], privileges: { all: { - api: ['casesSuggestUserProfiles'], + api: ['casesSuggestUserProfiles', 'bulkGetUserProfiles'], app: ['kibana'], cases: { all: ['observabilityFixture'], @@ -43,6 +43,7 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu ui: [], }, read: { + api: ['casesSuggestUserProfiles', 'bulkGetUserProfiles'], app: ['kibana'], cases: { read: ['observabilityFixture'], diff --git a/x-pack/test/cases_api_integration/common/fixtures/plugins/security_solution/server/plugin.ts b/x-pack/test/cases_api_integration/common/fixtures/plugins/security_solution/server/plugin.ts index 36917706d719c..b22674d66db4c 100644 --- a/x-pack/test/cases_api_integration/common/fixtures/plugins/security_solution/server/plugin.ts +++ b/x-pack/test/cases_api_integration/common/fixtures/plugins/security_solution/server/plugin.ts @@ -54,7 +54,7 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu ui: [], }, read: { - api: ['bulkGetUserProfiles'], + api: ['casesSuggestUserProfiles', 'bulkGetUserProfiles'], app: ['kibana'], cases: { read: ['securitySolutionFixture'], diff --git a/x-pack/test/cases_api_integration/common/lib/authentication/index.ts b/x-pack/test/cases_api_integration/common/lib/authentication/index.ts index 5ca8ac3bcd9f7..65e82a2e4fbf3 100644 --- a/x-pack/test/cases_api_integration/common/lib/authentication/index.ts +++ b/x-pack/test/cases_api_integration/common/lib/authentication/index.ts @@ -10,7 +10,7 @@ import { Role, User, UserInfo } from './types'; import { obsOnly, secOnly, secOnlyNoDelete, secOnlyRead, users } from './users'; import { roles } from './roles'; import { spaces } from './spaces'; -import { loginUsers } from '../utils'; +import { loginUsers } from '../user_profiles'; export const getUserInfo = (user: User): UserInfo => ({ username: user.username, diff --git a/x-pack/test/cases_api_integration/common/lib/user_profiles.ts b/x-pack/test/cases_api_integration/common/lib/user_profiles.ts index e68de4418c9bf..aefe3d0b1c873 100644 --- a/x-pack/test/cases_api_integration/common/lib/user_profiles.ts +++ b/x-pack/test/cases_api_integration/common/lib/user_profiles.ts @@ -8,6 +8,9 @@ import type SuperTest from 'supertest'; import { UserProfileBulkGetParams, UserProfileServiceStart } from '@kbn/security-plugin/server'; +import { INTERNAL_SUGGEST_USER_PROFILES_URL } from '@kbn/cases-plugin/common/constants'; +import { SuggestUserProfilesRequest } from '@kbn/cases-plugin/common/api'; +import { UserProfileService } from '@kbn/cases-plugin/server/services'; import { superUser } from './authentication/users'; import { User } from './authentication/types'; import { getSpaceUrlPrefix } from './utils'; @@ -37,3 +40,45 @@ export const bulkGetUserProfiles = async ({ return profiles; }; + +export const suggestUserProfiles = async ({ + supertest, + req, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: SuperTest.SuperTest<SuperTest.Test>; + req: SuggestUserProfilesRequest; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): ReturnType<UserProfileService['suggest']> => { + const { body: profiles } = await supertest + .post(`${getSpaceUrlPrefix(auth.space)}${INTERNAL_SUGGEST_USER_PROFILES_URL}`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .send(req) + .expect(expectedHttpCode); + + return profiles; +}; + +export const loginUsers = async ({ + supertest, + users = [superUser], +}: { + supertest: SuperTest.SuperTest<SuperTest.Test>; + users?: User[]; +}) => { + for (const user of users) { + await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: user.username, password: user.password }, + }) + .expect(200); + } +}; diff --git a/x-pack/test/cases_api_integration/common/lib/utils.ts b/x-pack/test/cases_api_integration/common/lib/utils.ts index 85350fb43f1f2..1c801341a0e99 100644 --- a/x-pack/test/cases_api_integration/common/lib/utils.ts +++ b/x-pack/test/cases_api_integration/common/lib/utils.ts @@ -24,7 +24,6 @@ import { CASE_REPORTERS_URL, CASE_STATUS_URL, CASE_TAGS_URL, - INTERNAL_SUGGEST_USER_PROFILES_URL, } from '@kbn/cases-plugin/common/constants'; import { CasesConfigureRequest, @@ -54,7 +53,6 @@ import { BulkCreateCommentRequest, CommentType, CasesMetricsResponse, - SuggestUserProfilesRequest, } from '@kbn/cases-plugin/common/api'; import { getCaseUserActionUrl } from '@kbn/cases-plugin/common/api/helpers'; import { SignalHit } from '@kbn/security-solution-plugin/server/lib/detection_engine/signals/types'; @@ -62,7 +60,6 @@ import { ActionResult, FindActionResult } from '@kbn/actions-plugin/server/types import { ESCasesConfigureAttributes } from '@kbn/cases-plugin/server/services/configure/types'; import { ESCaseAttributes } from '@kbn/cases-plugin/server/services/cases/types'; import type { SavedObjectsRawDocSource } from '@kbn/core/server'; -import { UserProfileService } from '@kbn/cases-plugin/server/services'; import { User } from './authentication/types'; import { superUser } from './authentication/users'; import { getPostCaseRequest, postCaseReq } from './mock'; @@ -1348,45 +1345,3 @@ export const getReferenceFromEsResponse = ( esResponse: TransportResult<GetResponse<SavedObjectsRawDocSource>, unknown>, id: string ) => esResponse.body._source?.references?.find((r) => r.id === id); - -export const suggestUserProfiles = async ({ - supertest, - req, - expectedHttpCode = 200, - auth = { user: superUser, space: null }, -}: { - supertest: SuperTest.SuperTest<SuperTest.Test>; - req: SuggestUserProfilesRequest; - expectedHttpCode?: number; - auth?: { user: User; space: string | null }; -}): ReturnType<UserProfileService['suggest']> => { - const { body: profiles } = await supertest - .post(`${getSpaceUrlPrefix(auth.space)}${INTERNAL_SUGGEST_USER_PROFILES_URL}`) - .auth(auth.user.username, auth.user.password) - .set('kbn-xsrf', 'true') - .send(req) - .expect(expectedHttpCode); - - return profiles; -}; - -export const loginUsers = async ({ - supertest, - users = [superUser], -}: { - supertest: SuperTest.SuperTest<SuperTest.Test>; - users?: User[]; -}) => { - for (const user of users) { - await supertest - .post('/internal/security/login') - .set('kbn-xsrf', 'xxx') - .send({ - providerType: 'basic', - providerName: 'basic', - currentURL: '/', - params: { username: user.username, password: user.password }, - }) - .expect(200); - } -}; diff --git a/x-pack/test/cases_api_integration/common/lib/validation.ts b/x-pack/test/cases_api_integration/common/lib/validation.ts index c901631388f11..a84c733586ec0 100644 --- a/x-pack/test/cases_api_integration/common/lib/validation.ts +++ b/x-pack/test/cases_api_integration/common/lib/validation.ts @@ -7,6 +7,7 @@ import expect from '@kbn/expect'; import { CaseResponse, CasesByAlertId } from '@kbn/cases-plugin/common/api'; +import { xorWith, isEqual } from 'lodash'; /** * Ensure that the result of the alerts API request matches with the cases created for the test. @@ -29,13 +30,12 @@ export function validateCasesFromAlertIDResponse( * Compares two arrays to determine if they are sort of equal. This function returns true if the arrays contain the same * elements but the ordering does not matter. */ -export function arraysToEqual(array1?: object[], array2?: object[]) { +export function arraysToEqual<T>(array1?: T[], array2?: T[]) { if (!array1 || !array2 || array1.length !== array2.length) { return false; } - const array1AsSet = new Set(array1); - return array2.every((item) => array1AsSet.has(item)); + return xorWith(array1, array2, isEqual).length === 0; } /** diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/assignees.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/assignees.ts index b92ddadc32c08..fbe2672c17cbe 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/assignees.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/assignees.ts @@ -10,13 +10,14 @@ import expect from '@kbn/expect'; import { findCasesResp, getPostCaseRequest, postCaseReq } from '../../../../common/lib/mock'; import { createCase, - suggestUserProfiles, getCase, findCases, updateCase, deleteAllCaseItems, } from '../../../../common/lib/utils'; +import { suggestUserProfiles } from '../../../../common/lib/user_profiles'; + import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { bulkGetUserProfiles } from '../../../../common/lib/user_profiles'; import { superUser } from '../../../../common/lib/authentication/users'; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts index 65f3a36cbe487..3daa29c02b107 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts @@ -238,9 +238,11 @@ export default ({ getService }: FtrProviderContext): void => { it('returns the correct fields', async () => { const postedCase = await createCase(supertest, postCaseReq); + // all fields that contain the UserRT definition must be included here (aka created_by, closed_by, and updated_by) + // see https://github.com/elastic/kibana/issues/139503 const queryFields: Array<keyof CaseResponse | Array<keyof CaseResponse>> = [ - 'title', - ['title', 'description'], + ['title', 'created_by', 'closed_by', 'updated_by'], + ['title', 'description', 'created_by', 'closed_by', 'updated_by'], ]; for (const fields of queryFields) { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/suggest_user_profiles.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/suggest_user_profiles.ts index 177d5ffc06486..f5feab6f04557 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/suggest_user_profiles.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/suggest_user_profiles.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { loginUsers, suggestUserProfiles } from '../../../../common/lib/utils'; +import { loginUsers, suggestUserProfiles } from '../../../../common/lib/user_profiles'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { superUser, @@ -21,6 +21,19 @@ export default function ({ getService }: FtrProviderContext) { const supertestWithoutAuth = getService('supertestWithoutAuth'); describe('suggest_user_profiles', () => { + it('returns no suggestions when the owner is an empty array', async () => { + const profiles = await suggestUserProfiles({ + supertest: supertestWithoutAuth, + req: { + name: 'delete', + owners: [], + }, + auth: { user: superUser, space: 'space1' }, + }); + + expect(profiles.length).to.be(0); + }); + it('finds the profile for the user without deletion privileges', async () => { const profiles = await suggestUserProfiles({ supertest: supertestWithoutAuth, diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics_alerts.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics_alerts.ts index 1f58d4b72cea8..060b7dda22dea 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics_alerts.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics_alerts.ts @@ -51,7 +51,7 @@ export default ({ getService }: FtrProviderContext): void => { { id: '7eb51035-5582-4cb8-9db2-5e71ef09aa5c', name: 'Host-123', count: 2 }, { id: '7eb51035-5582-4cb8-9db2-5e71ef09aa5d', name: 'Host-100', count: 2 }, ]) - ); + ).to.be(true); }); it('returns the user metrics', async () => { @@ -69,7 +69,7 @@ export default ({ getService }: FtrProviderContext): void => { { name: '7bgwxrbmcu', count: 1 }, { name: 'jf9e87gsut', count: 1 }, ]) - ); + ).to.be(true); }); it('returns both the host and user metrics', async () => { @@ -86,7 +86,7 @@ export default ({ getService }: FtrProviderContext): void => { { id: '7eb51035-5582-4cb8-9db2-5e71ef09aa5c', name: 'Host-123', count: 2 }, { id: '7eb51035-5582-4cb8-9db2-5e71ef09aa5d', name: 'Host-100', count: 2 }, ]) - ); + ).to.be(true); expect(metrics.alerts?.users?.total).to.be(4); expect( @@ -96,7 +96,7 @@ export default ({ getService }: FtrProviderContext): void => { { name: '7bgwxrbmcu', count: 1 }, { name: 'jf9e87gsut', count: 1 }, ]) - ); + ).to.be(true); }); }); diff --git a/x-pack/test/cases_api_integration/spaces_only/tests/common/internal/suggest_user_profiles.ts b/x-pack/test/cases_api_integration/spaces_only/tests/common/internal/suggest_user_profiles.ts index 44245f9b10e12..c10c7a6b63997 100644 --- a/x-pack/test/cases_api_integration/spaces_only/tests/common/internal/suggest_user_profiles.ts +++ b/x-pack/test/cases_api_integration/spaces_only/tests/common/internal/suggest_user_profiles.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { suggestUserProfiles } from '../../../../common/lib/utils'; +import { suggestUserProfiles } from '../../../../common/lib/user_profiles'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export diff --git a/x-pack/test/functional/services/cases/api.ts b/x-pack/test/functional/services/cases/api.ts index ad4678adcafc3..983ee667a2ef8 100644 --- a/x-pack/test/functional/services/cases/api.ts +++ b/x-pack/test/functional/services/cases/api.ts @@ -13,12 +13,19 @@ import { createComment, updateCase, } from '../../../cases_api_integration/common/lib/utils'; +import { + loginUsers, + suggestUserProfiles, +} from '../../../cases_api_integration/common/lib/user_profiles'; +import { User } from '../../../cases_api_integration/common/lib/authentication/types'; + import { FtrProviderContext } from '../../ftr_provider_context'; import { generateRandomCaseWithoutConnector } from './helpers'; export function CasesAPIServiceProvider({ getService }: FtrProviderContext) { const kbnSupertest = getService('supertest'); const es = getService('es'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); return { async createCase(overwrites: Partial<CasePostRequest> = {}): Promise<CaseResponse> { @@ -76,5 +83,16 @@ export function CasesAPIServiceProvider({ getService }: FtrProviderContext) { }, }); }, + + async activateUserProfiles(users: User[]) { + await loginUsers({ + supertest: supertestWithoutAuth, + users, + }); + }, + + async suggestUserProfiles(options: Parameters<typeof suggestUserProfiles>[0]['req']) { + return suggestUserProfiles({ supertest: kbnSupertest, req: options }); + }, }; } diff --git a/x-pack/test/functional/services/cases/common.ts b/x-pack/test/functional/services/cases/common.ts index 5b854979adfc1..8a61358d04521 100644 --- a/x-pack/test/functional/services/cases/common.ts +++ b/x-pack/test/functional/services/cases/common.ts @@ -89,5 +89,17 @@ export function CasesCommonServiceProvider({ getService, getPageObject }: FtrPro } }); }, + + async setSearchTextInAssigneesPopover(text: string) { + await ( + await (await find.byClassName('euiContextMenuPanel')).findByClassName('euiFieldSearch') + ).type(text); + await header.waitUntilLoadingHasFinished(); + }, + + async selectFirstRowInAssigneesPopover() { + await (await find.byClassName('euiSelectableListItem__content')).click(); + await header.waitUntilLoadingHasFinished(); + }, }; } diff --git a/x-pack/test/functional/services/cases/create.ts b/x-pack/test/functional/services/cases/create.ts index e46201b1996c1..872113f1a51be 100644 --- a/x-pack/test/functional/services/cases/create.ts +++ b/x-pack/test/functional/services/cases/create.ts @@ -84,7 +84,7 @@ export function CasesCreateViewServiceProvider( }, async setCaseTags(tag: string) { - await comboBox.setCustom('comboBoxInput', tag); + await comboBox.setCustom('caseTags', tag); }, async assertCreateCaseFlyoutVisible(expectVisible = true) { diff --git a/x-pack/test/functional/services/cases/index.ts b/x-pack/test/functional/services/cases/index.ts index b4cfee637cb46..8ecabdac8c4c5 100644 --- a/x-pack/test/functional/services/cases/index.ts +++ b/x-pack/test/functional/services/cases/index.ts @@ -20,7 +20,7 @@ export function CasesServiceProvider(context: FtrProviderContext) { return { api: CasesAPIServiceProvider(context), common: casesCommon, - casesTable: CasesTableServiceProvider(context), + casesTable: CasesTableServiceProvider(context, casesCommon), create: CasesCreateViewServiceProvider(context, casesCommon), navigation: CasesNavigationProvider(context), singleCase: CasesSingleViewServiceProvider(context), diff --git a/x-pack/test/functional/services/cases/list.ts b/x-pack/test/functional/services/cases/list.ts index 95b0a746db8ca..a5f650198cf22 100644 --- a/x-pack/test/functional/services/cases/list.ts +++ b/x-pack/test/functional/services/cases/list.ts @@ -10,8 +10,12 @@ import { CaseStatuses } from '@kbn/cases-plugin/common'; import { CaseSeverityWithAll } from '@kbn/cases-plugin/common/ui'; import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { CasesCommon } from './common'; -export function CasesTableServiceProvider({ getService, getPageObject }: FtrProviderContext) { +export function CasesTableServiceProvider( + { getService, getPageObject }: FtrProviderContext, + casesCommon: CasesCommon +) { const common = getPageObject('common'); const testSubjects = getService('testSubjects'); const find = getService('find'); @@ -132,13 +136,11 @@ export function CasesTableServiceProvider({ getService, getPageObject }: FtrProv await testSubjects.click(`case-severity-filter-${severity}`); }, - async filterByReporter(reporter: string) { - await common.clickAndValidate( - 'options-filter-popover-button-Reporter', - `options-filter-popover-item-${reporter}` - ); + async filterByAssignee(assignee: string) { + await common.clickAndValidate('options-filter-popover-button-assignees', 'euiSelectableList'); - await testSubjects.click(`options-filter-popover-item-${reporter}`); + await casesCommon.setSearchTextInAssigneesPopover(assignee); + await casesCommon.selectFirstRowInAssigneesPopover(); }, async filterByOwner(owner: string) { diff --git a/x-pack/test/functional/services/cases/single_case_view.ts b/x-pack/test/functional/services/cases/single_case_view.ts index 2db687f514778..6bdd35ee642e5 100644 --- a/x-pack/test/functional/services/cases/single_case_view.ts +++ b/x-pack/test/functional/services/cases/single_case_view.ts @@ -107,5 +107,15 @@ export function CasesSingleViewServiceProvider({ getService, getPageObject }: Ft `Expected case description to be '${expectedDescription}' (got '${actualDescription}')` ); }, + + async openAssigneesPopover() { + await common.clickAndValidate('case-view-assignees-edit-button', 'euiSelectableList'); + await header.waitUntilLoadingHasFinished(); + }, + + async closeAssigneesPopover() { + await testSubjects.click('case-refresh'); + await header.waitUntilLoadingHasFinished(); + }, }; } diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/common/users.ts b/x-pack/test/functional_with_es_ssl/apps/cases/common/users.ts index 282072dbb8dce..8d213e5b78075 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/common/users.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/common/users.ts @@ -30,4 +30,10 @@ export const casesAllUser: User = { roles: [casesAll.name], }; -export const users = [casesReadDeleteUser, casesNoDeleteUser, casesAllUser]; +export const casesAllUser2: User = { + username: 'cases_all_user2', + password: 'password', + roles: [casesAll.name], +}; + +export const users = [casesReadDeleteUser, casesNoDeleteUser, casesAllUser, casesAllUser2]; diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts index a825bda9b90ee..ec8e05ceb9b9d 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts @@ -10,6 +10,11 @@ import { CaseStatuses } from '@kbn/cases-plugin/common'; import { CaseSeverity } from '@kbn/cases-plugin/common/api'; import { SeverityAll } from '@kbn/cases-plugin/common/ui'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { + createUsersAndRoles, + deleteUsersAndRoles, +} from '../../../cases_api_integration/common/lib/authentication'; +import { users, roles, casesAllUser, casesAllUser2 } from './common'; export default ({ getPageObject, getService }: FtrProviderContext) => { const header = getPageObject('header'); @@ -85,14 +90,20 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const caseTitle = 'matchme'; before(async () => { + await createUsersAndRoles(getService, users, roles); + await cases.api.activateUserProfiles([casesAllUser, casesAllUser2]); + + const profiles = await cases.api.suggestUserProfiles({ name: 'all', owners: ['cases'] }); + await cases.api.createCase({ title: caseTitle, tags: ['one'], description: 'lots of information about an incident', }); await cases.api.createCase({ title: 'test2', tags: ['two'] }); - await cases.api.createCase({ title: 'test3' }); - await cases.api.createCase({ title: 'test4' }); + await cases.api.createCase({ title: 'test3', assignees: [{ uid: profiles[0].uid }] }); + await cases.api.createCase({ title: 'test4', assignees: [{ uid: profiles[1].uid }] }); + await header.waitUntilLoadingHasFinished(); await cases.casesTable.waitForCasesToBeListed(); }); @@ -108,6 +119,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { after(async () => { await cases.api.deleteAllCases(); await cases.casesTable.waitForCasesToBeDeleted(); + await deleteUsersAndRoles(getService, users, roles); }); it('filters cases from the list using a full string match', async () => { @@ -186,19 +198,20 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await cases.casesTable.validateCasesTableHasNthRows(1); }); - /** - * TODO: Improve the test by creating a case from a - * different user and filter by the new user - * and not the default one - */ - it('filters cases by reporter', async () => { - await cases.casesTable.filterByReporter('elastic'); - await cases.casesTable.validateCasesTableHasNthRows(4); + it('filters cases by the first cases all user assignee', async () => { + await cases.casesTable.filterByAssignee('all'); + await cases.casesTable.validateCasesTableHasNthRows(1); + }); + + it('filters cases by the casesAllUser2 assignee', async () => { + await cases.casesTable.filterByAssignee('2'); + await cases.casesTable.validateCasesTableHasNthRows(1); }); }); describe('severity filtering', () => { before(async () => { + await cases.navigation.navigateToApp(); await cases.api.createCase({ severity: CaseSeverity.LOW }); await cases.api.createCase({ severity: CaseSeverity.LOW }); await cases.api.createCase({ severity: CaseSeverity.HIGH }); @@ -207,6 +220,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await header.waitUntilLoadingHasFinished(); await cases.casesTable.waitForCasesToBeListed(); }); + beforeEach(async () => { /** * There is no easy way to clear the filtering. diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts index 223127125e66d..52540169a4c8d 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts @@ -10,6 +10,11 @@ import uuid from 'uuid'; import { CaseStatuses } from '@kbn/cases-plugin/common'; import { CaseSeverity } from '@kbn/cases-plugin/common/api'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { + createUsersAndRoles, + deleteUsersAndRoles, +} from '../../../cases_api_integration/common/lib/authentication'; +import { users, roles, casesAllUser } from './common'; export default ({ getPageObject, getService }: FtrProviderContext) => { const header = getPageObject('header'); @@ -18,21 +23,12 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const cases = getService('cases'); const retry = getService('retry'); const comboBox = getService('comboBox'); + const security = getPageObject('security'); + const kibanaServer = getService('kibanaServer'); describe('View case', () => { describe('properties', () => { - // create the case to test on - before(async () => { - await cases.navigation.navigateToApp(); - await cases.api.createNthRandomCases(1); - await cases.casesTable.waitForCasesToBeListed(); - await cases.casesTable.goToFirstListedCase(); - await header.waitUntilLoadingHasFinished(); - }); - - after(async () => { - await cases.api.deleteAllCases(); - }); + createOneCaseBeforeDeleteAllAfter(getPageObject, getService); it('edits a case title from the case view page', async () => { const newTitle = `test-${uuid.v4()}`; @@ -167,18 +163,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); describe('actions', () => { - // create the case to test on - before(async () => { - await cases.navigation.navigateToApp(); - await cases.api.createNthRandomCases(1); - await cases.casesTable.waitForCasesToBeListed(); - await cases.casesTable.goToFirstListedCase(); - await header.waitUntilLoadingHasFinished(); - }); - - after(async () => { - await cases.api.deleteAllCases(); - }); + createOneCaseBeforeDeleteAllAfter(getPageObject, getService); it('deletes the case successfully', async () => { await cases.singleCase.deleteCase(); @@ -187,21 +172,12 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); describe('Severity field', () => { - before(async () => { - await cases.navigation.navigateToApp(); - await cases.api.createNthRandomCases(1); - await cases.casesTable.waitForCasesToBeListed(); - await cases.casesTable.goToFirstListedCase(); - await header.waitUntilLoadingHasFinished(); - }); - - after(async () => { - await cases.api.deleteAllCases(); - }); + createOneCaseBeforeDeleteAllAfter(getPageObject, getService); it('shows the severity field on the sidebar', async () => { await testSubjects.existOrFail('case-severity-selection'); }); + it('changes the severity level from the selector', async () => { await cases.common.selectSeverity(CaseSeverity.MEDIUM); await header.waitUntilLoadingHasFinished(); @@ -212,20 +188,128 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); }); - describe('Tabs', () => { - // create the case to test on + describe('Assignees field', () => { before(async () => { - await cases.navigation.navigateToApp(); - await cases.api.createNthRandomCases(1); - await cases.casesTable.waitForCasesToBeListed(); - await cases.casesTable.goToFirstListedCase(); - await header.waitUntilLoadingHasFinished(); + await createUsersAndRoles(getService, users, roles); + await cases.api.activateUserProfiles([casesAllUser]); }); after(async () => { - await cases.api.deleteAllCases(); + await deleteUsersAndRoles(getService, users, roles); + }); + + describe('unknown users', () => { + beforeEach(async () => { + await kibanaServer.importExport.load( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/8.5.0/cases_assignees.json' + ); + + await cases.navigation.navigateToApp(); + await cases.casesTable.waitForCasesToBeListed(); + await cases.casesTable.goToFirstListedCase(); + await header.waitUntilLoadingHasFinished(); + }); + + afterEach(async () => { + await kibanaServer.importExport.unload( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/8.5.0/cases_assignees.json' + ); + + await cases.api.deleteAllCases(); + }); + + it('shows the unknown assignee', async () => { + await testSubjects.existOrFail('user-profile-assigned-user-group-abc'); + }); + + it('removes the unknown assignee when selecting the remove all users in the popover', async () => { + await testSubjects.existOrFail('user-profile-assigned-user-group-abc'); + + await cases.singleCase.openAssigneesPopover(); + await cases.common.setSearchTextInAssigneesPopover('case'); + await cases.common.selectFirstRowInAssigneesPopover(); + + await (await find.byButtonText('Remove all assignees')).click(); + await cases.singleCase.closeAssigneesPopover(); + await testSubjects.missingOrFail('user-profile-assigned-user-group-abc'); + }); + }); + + describe('login with cases all user', () => { + before(async () => { + await security.forceLogout(); + await security.login(casesAllUser.username, casesAllUser.password); + await createAndNavigateToCase(getPageObject, getService); + }); + + after(async () => { + await cases.api.deleteAllCases(); + await security.forceLogout(); + }); + + it('assigns the case to the current user when clicking the assign to self link', async () => { + await testSubjects.click('case-view-assign-yourself-link'); + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user'); + }); }); + describe('logs in with default user', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService); + + afterEach(async () => { + await cases.singleCase.closeAssigneesPopover(); + }); + + it('shows the assign users popover when clicked', async () => { + await testSubjects.missingOrFail('euiSelectableList'); + + await cases.singleCase.openAssigneesPopover(); + }); + + it('assigns a user from the popover', async () => { + await cases.singleCase.openAssigneesPopover(); + await cases.common.setSearchTextInAssigneesPopover('case'); + await cases.common.selectFirstRowInAssigneesPopover(); + + // navigate out of the modal + await cases.singleCase.closeAssigneesPopover(); + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user'); + }); + }); + + describe('logs in with default user and creates case before each', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService); + + it('removes an assigned user', async () => { + await cases.singleCase.openAssigneesPopover(); + await cases.common.setSearchTextInAssigneesPopover('case'); + await cases.common.selectFirstRowInAssigneesPopover(); + + // navigate out of the modal + await cases.singleCase.closeAssigneesPopover(); + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('user-profile-assigned-user-group-cases_all_user'); + + // hover over the assigned user + await ( + await find.byCssSelector( + '[data-test-subj="user-profile-assigned-user-group-cases_all_user"]' + ) + ).moveMouseTo(); + + // delete the user + await testSubjects.click('user-profile-assigned-user-cross-cases_all_user'); + + await testSubjects.existOrFail('case-view-assign-yourself-link'); + }); + }); + }); + + describe('Tabs', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService); + it('shows the "activity" tab by default', async () => { await testSubjects.existOrFail('case-view-tab-title-activity'); await testSubjects.existOrFail('case-view-tab-content-activity'); @@ -239,3 +323,32 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); }); }; + +const createOneCaseBeforeDeleteAllAfter = ( + getPageObject: FtrProviderContext['getPageObject'], + getService: FtrProviderContext['getService'] +) => { + const cases = getService('cases'); + + before(async () => { + await createAndNavigateToCase(getPageObject, getService); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); +}; + +const createAndNavigateToCase = async ( + getPageObject: FtrProviderContext['getPageObject'], + getService: FtrProviderContext['getService'] +) => { + const header = getPageObject('header'); + const cases = getService('cases'); + + await cases.navigation.navigateToApp(); + await cases.api.createNthRandomCases(1); + await cases.casesTable.waitForCasesToBeListed(); + await cases.casesTable.goToFirstListedCase(); + await header.waitUntilLoadingHasFinished(); +}; diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/cases/kibana.json b/x-pack/test/functional_with_es_ssl/fixtures/plugins/cases/kibana.json index 56e1f9fd62a08..c7918341da86e 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/cases/kibana.json +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/cases/kibana.json @@ -3,7 +3,7 @@ "owner": { "name": "Response Ops", "githubTeam": "response-ops" }, "version": "1.0.0", "kibanaVersion": "kibana", - "requiredPlugins": ["cases", "embeddable", "lens", "kibanaReact", "esUiShared"], + "requiredPlugins": ["cases", "embeddable", "lens", "kibanaReact", "esUiShared", "security"], "server": true, "ui": true }