diff --git a/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx b/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx index bbb46a65e6..72e508a8c7 100644 --- a/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx @@ -24,7 +24,7 @@ import { useTimeFormat } from './useTimeFormat'; import { useVideoTimestamp } from './useVideoTimestamp'; import type { TaskFormV2SubmitPayload } from './task-modal-v2/types'; -import type { ActivityFeedV2Props, TransformedFeedItem, UserContact } from './types'; +import type { ActivityFeedV2Props, TransformedFeedItem } from './types'; import type { ElementsXhrError } from '../../../common/types/api'; import type { GroupMini, SelectorItem, UserMini } from '../../../common/types/core'; import type { TaskAssigneeCollection, TaskNew, TaskType, TaskUpdatePayload } from '../../../common/types/tasks'; @@ -86,19 +86,14 @@ const ActivityFeedV2 = ({ const knownIdsBeforePostRef = React.useRef | null>(null); const fetchUsers = React.useCallback( - async (inputValue: string): Promise => { + async (inputValue: string): Promise => { const trimmed = inputValue.trim(); if (!trimmed || !getMentionAsync) { return []; } try { const entries = await getMentionAsync(trimmed); - return entries.map((c: Record) => ({ - email: (c.email as string) ?? (c.login as string) ?? '', - id: Number(c.id) || 0, - name: (c.name as string) ?? '', - value: String(c.id), - })); + return entries.map(mapCollaboratorToUserContact); } catch { return []; } @@ -123,7 +118,7 @@ const ActivityFeedV2 = ({ ); const fetchAvatarUrls = React.useCallback( - async (userContacts: UserContact[]) => { + async (userContacts: UserContactType[]) => { const urls: Record = {}; if (getAvatarUrl) { await Promise.all( diff --git a/src/elements/content-sidebar/activity-feed-v2/__tests__/ActivityFeedV2.test.tsx b/src/elements/content-sidebar/activity-feed-v2/__tests__/ActivityFeedV2.test.tsx index cf7f19411b..fa97a76e7b 100644 --- a/src/elements/content-sidebar/activity-feed-v2/__tests__/ActivityFeedV2.test.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/__tests__/ActivityFeedV2.test.tsx @@ -234,11 +234,11 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => { }); test.each` - description | file - ${'can_comment is false'} | ${{ id: '12345', permissions: { can_comment: false } }} - ${'file is missing'} | ${undefined} - ${'permissions are missing'} | ${{ id: '12345' }} - ${'can_comment is undefined'} | ${{ id: '12345', permissions: {} }} + description | file + ${'can_comment is false'} | ${{ id: '12345', permissions: { can_comment: false } }} + ${'file is missing'} | ${undefined} + ${'permissions are missing'} | ${{ id: '12345' }} + ${'can_comment is undefined'} | ${{ id: '12345', permissions: {} }} `('should replace the editor with CommentsDisabled when $description', ({ file }) => { render( { expect(screen.getByTestId('activity-feed-root')).toBeVisible(); }); - test('should return empty array from fetchUsers when getMentionAsync is not provided', async () => { - render(); - expect(screen.getByTestId('activity-feed-root')).toBeVisible(); - }); - - test('should return empty array from fetchUsers when getMentionAsync rejects', async () => { - const getMentionAsync = jest.fn().mockRejectedValue(new Error('API error')); - render( - , - ); - expect(screen.getByTestId('activity-feed-root')).toBeVisible(); - }); - describe('mention popover behavior', () => { test('should pass allowEmptyQuery=true so the popover opens on @ before any character is typed', () => { render( @@ -663,8 +646,37 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => { ); }); + test('should return an empty array from fetchUsers when getMentionAsync is not provided', async () => { + render( + , + ); + + await expect(lastEditorProps.userSelectorProps?.fetchUsers?.('alice')).resolves.toEqual([]); + }); + + test('should return an empty array from fetchUsers when getMentionAsync rejects', async () => { + const getMentionAsync = jest.fn().mockRejectedValue(new Error('API error')); + render( + , + ); + + await expect(lastEditorProps.userSelectorProps?.fetchUsers?.('alice')).resolves.toEqual([]); + expect(getMentionAsync).toHaveBeenCalledWith('alice'); + }); + test('should skip the API call when fetchUsers is invoked with an empty query', async () => { - const getMentionAsync = jest.fn().mockResolvedValue([{ id: '1', name: 'foo' }]); + const getMentionAsync = jest + .fn() + .mockResolvedValue([{ id: '1', item: { id: '1', name: 'foo', type: 'user' }, name: 'foo' }]); render( { }); test('should skip the API call when fetchUsers is invoked with a whitespace-only query', async () => { - const getMentionAsync = jest.fn().mockResolvedValue([{ id: '1', name: 'foo' }]); + const getMentionAsync = jest + .fn() + .mockResolvedValue([{ id: '1', item: { id: '1', name: 'foo', type: 'user' }, name: 'foo' }]); render( { expect(result).toEqual([]); }); - test('should call getMentionAsync with the trimmed value and shape results for a non-empty query', async () => { + test('should call getMentionAsync with the trimmed value and map SelectorItem.item email into contacts', async () => { const getMentionAsync = jest.fn().mockResolvedValue([ - { email: 'a@b.com', id: '7', name: 'Alice' }, - { id: '8', login: 'bob@b.com', name: 'Bob' }, + { + id: '7', + item: { email: 'a@b.com', id: '7', login: 'a@b.com', name: 'Alice', type: 'user' }, + name: 'Alice', + }, + { + id: '8', + item: { email: 'bob@b.com', id: '8', login: 'bob@b.com', name: 'Bob', type: 'user' }, + name: 'Bob', + }, ]); render( { expect(getMentionAsync).toHaveBeenCalledWith('al'); expect(result).toEqual([ - { email: 'a@b.com', id: 7, name: 'Alice', value: '7' }, - { email: 'bob@b.com', id: 8, name: 'Bob', value: '8' }, + { email: 'a@b.com', id: 7, name: 'Alice', type: 'user', value: '7' }, + { email: 'bob@b.com', id: 8, name: 'Bob', type: 'user', value: '8' }, ]); }); + test('should map mention contacts with empty email when SelectorItem.item.email is missing', async () => { + const getMentionAsync = jest.fn().mockResolvedValue([ + { + id: '11', + item: { id: '11', login: 'login-only@b.com', name: 'Carol', type: 'user' }, + name: 'Carol', + }, + ]); + render( + , + ); + + const result = await lastEditorProps.userSelectorProps?.fetchUsers?.('car'); + + expect(result).toEqual([{ email: '', id: 11, name: 'Carol', type: 'user', value: '11' }]); + }); + test('should render the V1-style start prompt via renderEmpty when value is empty', () => { render( { , + file={mockFileWithCommentPermission} + />, ); mockScrollTo.mockClear(); @@ -1276,8 +1320,8 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => { , + file={mockFileWithCommentPermission} + />, ); expect(mockScrollTo).not.toHaveBeenCalled(); diff --git a/src/elements/content-sidebar/activity-feed-v2/types.ts b/src/elements/content-sidebar/activity-feed-v2/types.ts index 7bdc70b00e..18147e2ad3 100644 --- a/src/elements/content-sidebar/activity-feed-v2/types.ts +++ b/src/elements/content-sidebar/activity-feed-v2/types.ts @@ -5,6 +5,7 @@ import type { AppActivityItemProps, TaskItemProps, VersionItemProps } from '@box/activity-feed'; import type { AnnotationBadgeTargetType, TextMessageTypeV2 as TextMessageType } from '@box/threaded-annotations'; +import type { UserContactType } from '@box/user-selector'; import type { Annotation, AnnotationPermission } from '../../../common/types/annotations'; import type { BoxCommentPermission, CommentFeedItemType, FeedItems, FeedItemStatus } from '../../../common/types/feed'; @@ -19,17 +20,10 @@ export type AvatarUrlMap = Readonly>; export type GetAvatarUrl = (userId: string) => Promise; -export type UserContact = { - email: string; - id: number; - name: string; - value: string; -}; - export type UserSelectorProps = { ariaRoleDescription: string; - fetchAvatarUrls: (userContacts: UserContact[]) => Promise>; - fetchUsers: (inputValue: string) => Promise; + fetchAvatarUrls: (userContacts: UserContactType[]) => Promise>; + fetchUsers: (inputValue: string) => Promise; loadingAriaLabel: string; }; @@ -101,7 +95,7 @@ export type ActivityFeedV2Props = { file?: ActivityFeedV2File; getApproverAsync?: (searchStr: string) => Promise[]>; getAvatarUrl?: GetAvatarUrl; - getMentionAsync?: (searchStr: string) => Promise>>; + getMentionAsync?: (searchStr: string) => Promise[]>; getTaskCollaborators?: (task: TaskNew) => Promise; getViewer?: () => ViewerHandle | null; hasTasks?: boolean;