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/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/case_view_activity.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx index 3c87ca20a74f4..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,7 +7,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingContent } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; -import { isEqual } from 'lodash'; +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'; @@ -58,11 +58,14 @@ export const CaseViewActivity = ({ [caseData.assignees] ); - const { data: userProfiles, isLoading: isLoadingUserProfiles } = useBulkGetUserProfiles({ - uids: assignees, + const userActionProfileUids = Array.from(userActionsData?.profileUids?.values() ?? []); + const uidsToRetrieve = uniq([...userActionProfileUids, ...assignees]); + + const { data: userProfiles, isFetching: isLoadingUserProfiles } = useBulkGetUserProfiles({ + uids: uidsToRetrieve, }); - const { data: currentUserProfile, isLoading: isLoadingCurrentUserProfile } = + const { data: currentUserProfile, isFetching: isLoadingCurrentUserProfile } = useGetCurrentUserProfile(); const onShowAlertDetails = useCallback( @@ -151,10 +154,12 @@ export const CaseViewActivity = ({ {isLoadingUserActions && ( )} - {!isLoadingUserActions && userActionsData && ( + {!isLoadingUserActions && userActionsData && userProfiles && ( = ({ [] ); - const { data: userProfiles, isLoading: isLoadingSuggest } = useSuggestUserProfiles({ + const { data: userProfiles, isFetching: isLoadingSuggest } = useSuggestUserProfiles({ name: searchTerm, owners: owner, }); @@ -173,7 +173,7 @@ const SuggestUsersPopoverComponent: React.FC = ({ searchPlaceholder: i18n.SEARCH_USERS, clearButtonLabel: i18n.REMOVE_ASSIGNEES, emptyMessage: , - noMatchesMessage: , + noMatchesMessage: searchResultProfiles ? : , }} /> ); @@ -183,7 +183,7 @@ SuggestUsersPopoverComponent.displayName = 'SuggestUsersPopover'; export const SuggestUsersPopover = React.memo(SuggestUsersPopoverComponent); -const sortProfiles = (profiles?: UserProfileWithAvatar[]) => { +const sortProfiles = (profiles?: UserProfileWithAvatar[] | null) => { if (!profiles) { return; } 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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 }) => ( + + <>{i18n.THEMSELVES} + +); +Themselves.displayName = 'Themselves'; + +const AssigneeComponent: React.FC<{ + assignee: Assignee; + index: number; + numOfAssigness: number; +}> = ({ assignee, index, numOfAssigness }) => ( + + + {getName(assignee.profile?.user)} + + +); +AssigneeComponent.displayName = 'Assignee'; + +interface AssigneesProps { + assignees: Assignee[]; + createdByUser: SnakeToCamelCase; +} + +const AssigneesComponent = ({ assignees, createdByUser }: AssigneesProps) => ( + <> + {assignees.length > 0 && ( + + {assignees.map((assignee, index) => { + const usernameDataTestSubj = getUsernameDataTestSubj(assignee); + + return ( + + + {doesAssigneeMatchCreatedByUser(assignee, createdByUser) ? ( + + ) : ( + + )} + + + ); + })} + + )} + +); +AssigneesComponent.displayName = 'Assignees'; +const Assignees = memo(AssigneesComponent); + +const doesAssigneeMatchCreatedByUser = ( + assignee: Assignee, + createdByUser: SnakeToCamelCase +) => { + 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, + userProfiles?: Map +) => { + const assignees = userAction.payload.assignees.map((assignee) => { + const profile = userProfiles?.get(assignee.uid); + return { + uid: assignee.uid, + profile, + }; + }); + + return ( + + + {userAction.action === Actions.add && i18n.ASSIGNED} + {userAction.action === Actions.delete && i18n.UNASSIGNED} + + + + + + ); +}; export const createAssigneesUserActionBuilder: UserActionBuilder = ({ userAction, handleOutlineComment, + userProfiles, }) => ({ build: () => { - return []; + const assigneesUserAction = userAction as UserActionResponse; + 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..44f5c523e1048 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,7 @@ const onShowAlertDetails = jest.fn(); const defaultProps = { caseServices: {}, caseUserActions: [], + userProfiles: new Map(), connectors: [], actionsNavigation: { href: jest.fn(), onClick: jest.fn() }, getRuleDetailsHref: jest.fn(), @@ -440,6 +441,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/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..3ea4ab99a90ad 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'; @@ -21,6 +22,8 @@ import { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_ export interface UserActionTreeProps { caseServices: CaseServices; caseUserActions: CaseUserActions[]; + userProfiles: Map; + currentUserProfile?: UserProfileWithAvatar; data: Case; getRuleDetailsHref?: RuleDetailsNavigation['href']; actionsNavigation?: ActionsNavigation; @@ -38,6 +41,8 @@ export type SupportedUserActionTypes = keyof Omit; + currentUserProfile?: UserProfileWithAvatar; externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry; persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry; userAction: CaseUserActions; diff --git a/x-pack/plugins/cases/public/components/user_profiles/data_test_subject.ts b/x-pack/plugins/cases/public/components/user_profiles/data_test_subject.ts new file mode 100644 index 0000000000000..23d952738aa4d --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_profiles/data_test_subject.ts @@ -0,0 +1,12 @@ +/* + * 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 { Assignee } from './types'; + +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 index f6b0f2b2faecb..4abd9f276abaa 100644 --- a/x-pack/plugins/cases/public/components/user_profiles/display_name.ts +++ b/x-pack/plugins/cases/public/components/user_profiles/display_name.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { UserProfileUserInfo } from '@kbn/user-profile-components/target_types/user_profile'; +import { getUserDisplayName, UserProfileUserInfo } from '@kbn/user-profile-components'; +import { isEmpty } from 'lodash'; import * as i18n from './translations'; export const getName = (user?: UserProfileUserInfo): string => { @@ -13,5 +14,6 @@ export const getName = (user?: UserProfileUserInfo): string => { return i18n.UNKNOWN; } - return user.full_name ?? user.email ?? user.username; + const displayName = getUserDisplayName(user); + return !isEmpty(displayName) ? displayName : i18n.UNKNOWN; }; 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 index a851e5f79176b..9c837997b9a1f 100644 --- a/x-pack/plugins/cases/public/components/user_profiles/user_tooltip.tsx +++ b/x-pack/plugins/cases/public/components/user_profiles/user_tooltip.tsx @@ -91,7 +91,7 @@ export interface UserToolTipProps { const UserToolTipComponent: React.FC = ({ children, profile }) => { return ( } data-test-subj="user-profile-tooltip" diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 92e601dd0c9e9..dc0f7ca51ddfe 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -688,6 +688,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( + () => 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( + () => 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( + () => 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>((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/user_profiles/use_suggest_user_profiles.ts b/x-pack/plugins/cases/public/containers/user_profiles/use_suggest_user_profiles.ts index 8e50dd0be114c..c311eb5a55d1f 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 @@ -31,7 +31,7 @@ export const useSuggestUserProfiles = ({ const toasts = useToasts(); - return useQuery( + return useQuery( [ USER_PROFILES_CACHE_KEY, USER_PROFILES_SUGGEST_CACHE_KEY, @@ -39,8 +39,9 @@ export const useSuggestUserProfiles = ({ ], () => { if (isEmpty(name)) { - return []; + return null; } + const abortCtrlRef = new AbortController(); return suggestUserProfiles({ http, @@ -67,4 +68,4 @@ export const useSuggestUserProfiles = ({ ); }; -export type UseSuggestUserProfiles = UseQueryResult; +export type UseSuggestUserProfiles = UseQueryResult; 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> = [ - 'title', - ['title', 'description'], + ['title', 'created_by', 'closed_by', 'updated_by'], + ['title', 'description', 'created_by', 'closed_by', 'updated_by'], ]; for (const fields of queryFields) {