Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -86,19 +86,14 @@ const ActivityFeedV2 = ({
const knownIdsBeforePostRef = React.useRef<Set<string> | null>(null);

const fetchUsers = React.useCallback(
async (inputValue: string): Promise<UserContact[]> => {
async (inputValue: string): Promise<UserContactType[]> => {
const trimmed = inputValue.trim();
if (!trimmed || !getMentionAsync) {
return [];
}
try {
const entries = await getMentionAsync(trimmed);
return entries.map((c: Record<string, unknown>) => ({
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 [];
}
Expand All @@ -123,7 +118,7 @@ const ActivityFeedV2 = ({
);

const fetchAvatarUrls = React.useCallback(
async (userContacts: UserContact[]) => {
async (userContacts: UserContactType[]) => {
const urls: Record<string, string> = {};
if (getAvatarUrl) {
await Promise.all(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ActivityFeedV2
Expand Down Expand Up @@ -618,23 +618,6 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => {
expect(screen.getByTestId('activity-feed-root')).toBeVisible();
});

test('should return empty array from fetchUsers when getMentionAsync is not provided', async () => {
render(<ActivityFeedV2 currentUser={mockCurrentUser} feedItems={[] as ActivityFeedV2Props['feedItems']} />);
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(
<ActivityFeedV2
currentUser={mockCurrentUser}
feedItems={[] as ActivityFeedV2Props['feedItems']}
getMentionAsync={getMentionAsync}
/>,
);
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(
Expand Down Expand Up @@ -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(
<ActivityFeedV2
currentUser={mockCurrentUser}
feedItems={[] as ActivityFeedV2Props['feedItems']}
file={mockFileWithCommentPermission}
/>,
);

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(
<ActivityFeedV2
currentUser={mockCurrentUser}
feedItems={[] as ActivityFeedV2Props['feedItems']}
file={mockFileWithCommentPermission}
getMentionAsync={getMentionAsync}
/>,
);

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(
<ActivityFeedV2
currentUser={mockCurrentUser}
Expand All @@ -681,7 +693,9 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => {
});

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(
<ActivityFeedV2
currentUser={mockCurrentUser}
Expand All @@ -697,10 +711,18 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => {
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(
<ActivityFeedV2
Expand All @@ -715,11 +737,33 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => {

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(
<ActivityFeedV2
currentUser={mockCurrentUser}
feedItems={[] as ActivityFeedV2Props['feedItems']}
file={mockFileWithCommentPermission}
getMentionAsync={getMentionAsync}
/>,
);

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(
<ActivityFeedV2
Expand Down Expand Up @@ -1267,17 +1311,17 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => {
<ActivityFeedV2
currentUser={numericCurrentUser}
feedItems={[mockComment] as ActivityFeedV2Props['feedItems']}
file={mockFileWithCommentPermission}
/>,
file={mockFileWithCommentPermission}
/>,
);
mockScrollTo.mockClear();

rerender(
<ActivityFeedV2
currentUser={numericCurrentUser}
feedItems={[mockComment, strangerComment] as ActivityFeedV2Props['feedItems']}
file={mockFileWithCommentPermission}
/>,
file={mockFileWithCommentPermission}
/>,
);

expect(mockScrollTo).not.toHaveBeenCalled();
Expand Down
14 changes: 4 additions & 10 deletions src/elements/content-sidebar/activity-feed-v2/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,17 +20,10 @@ export type AvatarUrlMap = Readonly<Record<string, string>>;

export type GetAvatarUrl = (userId: string) => Promise<string | null | undefined>;

export type UserContact = {
email: string;
id: number;
name: string;
value: string;
};

export type UserSelectorProps = {
ariaRoleDescription: string;
fetchAvatarUrls: (userContacts: UserContact[]) => Promise<Record<string, string>>;
fetchUsers: (inputValue: string) => Promise<UserContact[]>;
fetchAvatarUrls: (userContacts: UserContactType[]) => Promise<Record<string, string>>;
fetchUsers: (inputValue: string) => Promise<UserContactType[]>;
loadingAriaLabel: string;
};

Expand Down Expand Up @@ -101,7 +95,7 @@ export type ActivityFeedV2Props = {
file?: ActivityFeedV2File;
getApproverAsync?: (searchStr: string) => Promise<SelectorItem<UserMini | GroupMini>[]>;
getAvatarUrl?: GetAvatarUrl;
getMentionAsync?: (searchStr: string) => Promise<Array<Record<string, unknown>>>;
getMentionAsync?: (searchStr: string) => Promise<SelectorItem<UserMini | GroupMini>[]>;
getTaskCollaborators?: (task: TaskNew) => Promise<TaskAssigneeCollection>;
getViewer?: () => ViewerHandle | null;
hasTasks?: boolean;
Expand Down
Loading