diff --git a/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx b/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx index 84ee811a76..39dd23ae03 100644 --- a/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx @@ -18,7 +18,7 @@ import TaskModalV2 from './task-modal-v2'; import FeedItemRow from './FeedItemRow'; import { serializeEditorContent } from './helpers'; import { mapCollaboratorToUserContact } from './task-modal-v2/utils/contactMapping'; -import { transformFeedItem } from './transformers'; +import { transformFeedItem, transformTaskAssignees } from './transformers'; import { useAvatarUrls } from './useAvatarUrls'; import { useTimeFormat } from './useTimeFormat'; import { useVideoTimestamp } from './useVideoTimestamp'; @@ -230,6 +230,24 @@ const ActivityFeedV2 = ({ const avatarUrls = useAvatarUrls(feedItems, getAvatarUrl); + // Loads the full assignee list (limit=API_PAGE_LIMIT via getTaskCollaborators) when the + // assignee list's "Show more" is clicked on a task whose embedded first page of assignees + // was incomplete (assigned_to.next_marker present, i.e. hasNextPage). Rejections are + // rethrown so AssigneeList can render its inline load error. + const handleTaskLoadAllAssignees = React.useMemo(() => { + if (!getTaskCollaborators) return undefined; + return async (task: TaskNew) => { + try { + const collection = await getTaskCollaborators(task); + return transformTaskAssignees(collection.entries, avatarUrls); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`ActivityFeedV2: failed to load assignees for task "${task.id}"`, error); + throw error; + } + }; + }, [avatarUrls, getTaskCollaborators]); + const transformedItems: TransformedFeedItem[] = React.useMemo(() => { if (!feedItems) return []; return feedItems.reduce((acc, item) => { @@ -507,6 +525,7 @@ const ActivityFeedV2 = ({ onTaskAssignmentUpdate={onTaskAssignmentUpdate} onTaskDelete={onTaskDelete} onTaskEdit={onTaskUpdate ? handleTaskEdit : undefined} + onTaskLoadAllAssignees={handleTaskLoadAllAssignees} onTaskView={onTaskView} onVersionHistoryClick={onVersionHistoryClick} timeFormat={timeFormat} diff --git a/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx b/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx index 311a08b7e9..5a6682615f 100644 --- a/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx @@ -18,7 +18,7 @@ import { annotationTargetToBadge } from './transformers'; import { formatByTimeFormat } from './useTimeFormat'; import { seekVideoToMs } from './useVideoTimestamp'; -import type { OnReplyDelete, OnReplyUpdate, TransformedFeedItem, UserSelectorProps } from './types'; +import type { OnReplyDelete, OnReplyUpdate, TaskItemProps, TransformedFeedItem, UserSelectorProps } from './types'; import { FEED_ITEM_TYPE_ANNOTATION, @@ -61,6 +61,7 @@ type FeedItemRowProps = { onTaskAssignmentUpdate?: (taskId: string, taskAssignmentId: string, status: TaskCollabStatus) => void; onTaskDelete?: (task: TaskNew) => void; onTaskEdit?: (task: TaskNew) => void; + onTaskLoadAllAssignees?: (task: TaskNew) => Promise; onTaskView?: (id: string, isCreator: boolean) => void; onVersionHistoryClick?: (version: { id: string; version_number: number }) => void; timeFormat: TimeFormat; @@ -101,6 +102,7 @@ const FeedItemRow = ({ onTaskAssignmentUpdate, onTaskDelete, onTaskEdit, + onTaskLoadAllAssignees, onTaskView, onVersionHistoryClick, timeFormat, @@ -269,6 +271,9 @@ const FeedItemRow = ({ onComplete={canActOnAssignment ? () => handleAssignmentUpdate(TASK_NEW_COMPLETED) : undefined} onDelete={onTaskDelete ? () => onTaskDelete(item.originalTask) : undefined} onEdit={onTaskEdit ? () => onTaskEdit(item.originalTask) : undefined} + onLoadAllAssignee={ + onTaskLoadAllAssignees ? () => onTaskLoadAllAssignees(item.originalTask) : undefined + } onReject={canActOnAssignment ? () => handleAssignmentUpdate(TASK_NEW_REJECTED) : undefined} onView={ onTaskView 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 9d5c83086e..003c1a1cb0 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 @@ -28,12 +28,18 @@ const mockScrollTo = jest.fn(() => true); type FilterMenuProps = { children?: React.ReactNode; hasActiveFilters?: boolean }; type FilterOptionProps = { checked?: boolean; onCheckedChange?: (checked: boolean) => void }; type RootProps = React.ComponentProps; +type TaskListItemProps = { + hasNextPage?: boolean; + id: string; + onLoadAllAssignee?: () => Promise; +}; let lastFilterMenuProps: FilterMenuProps = {}; let lastShowResolvedOptionProps: FilterOptionProps = {}; let lastMentionMeOptionProps: FilterOptionProps = {}; let lastEditorProps: Partial = {}; let lastRootProps: Partial = {}; let lastTaskModalProps: Partial = {}; +let lastTaskItemProps: Partial = {}; jest.mock('../task-modal-v2', () => ({ __esModule: true, @@ -55,7 +61,10 @@ jest.mock('@box/activity-feed', () => { ActivityFeedList.AppActivity = (props: { id: string }) => (
AppActivity
); - ActivityFeedList.Task = (props: { id: string }) =>
Task
; + ActivityFeedList.Task = (props: TaskListItemProps) => { + lastTaskItemProps = props; + return
Task
; + }; ActivityFeedList.ThreadedAnnotation = (props: { messages?: Array<{ id: string }> }) => (
ThreadedAnnotation
); @@ -186,6 +195,7 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => { lastEditorProps = {}; lastRootProps = {}; lastTaskModalProps = {}; + lastTaskItemProps = {}; mockSerializeMentionMarkup.mockImplementation((doc: unknown) => ({ hasMention: false, text: JSON.stringify(doc), @@ -252,6 +262,109 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => { expect(screen.getByTestId('task-task-1')).toBeVisible(); }); + describe('task assignee loading', () => { + // First page of assignees is partial (next_marker set), so hasNextPage is passed as true + // to the Task item (which is what makes the real AssigneeList offer "Show more") + const taskWithMoreAssignees = { + ...mockTask, + assigned_to: { + entries: [ + { + id: 'assignment-1', + permissions: { can_delete: true, can_update: true }, + role: 'ASSIGNEE', + status: 'NOT_STARTED', + target: { id: 'user-2', name: 'Assignee One', type: 'user' }, + type: 'task_collaborator', + }, + ], + limit: 20, + next_marker: 'marker-1', + }, + }; + + test('should fetch and transform the full assignee list when onLoadAllAssignee fires', async () => { + const getTaskCollaborators = jest.fn().mockResolvedValue({ + entries: [ + { + id: 'assignment-1', + permissions: { can_delete: true, can_update: true }, + role: 'ASSIGNEE', + status: 'NOT_STARTED', + target: { id: 'user-2', name: 'Assignee One', type: 'user' }, + type: 'task_collaborator', + }, + { + completed_at: '2024-03-02T00:00:00Z', + id: 'assignment-2', + permissions: { can_delete: false, can_update: false }, + role: 'ASSIGNEE', + status: 'COMPLETED', + target: { id: 'user-3', name: 'Assignee Two', type: 'user' }, + type: 'task_collaborator', + }, + ], + limit: 1000, + next_marker: null, + }); + render( + , + ); + + expect(lastTaskItemProps.hasNextPage).toBe(true); + + const result = await lastTaskItemProps.onLoadAllAssignee?.(); + + expect(getTaskCollaborators).toHaveBeenCalledWith(taskWithMoreAssignees); + expect(result).toEqual([ + expect.objectContaining({ id: 'user-2', name: 'Assignee One', status: 'NOT_STARTED' }), + expect.objectContaining({ + completedAt: new Date('2024-03-02T00:00:00Z').getTime(), + id: 'user-3', + name: 'Assignee Two', + status: 'COMPLETED', + }), + ]); + }); + + test('should omit onLoadAllAssignee when getTaskCollaborators is not provided', () => { + render( + , + ); + + expect(lastTaskItemProps.hasNextPage).toBe(true); + expect(lastTaskItemProps.onLoadAllAssignee).toBeUndefined(); + }); + + test('should log and rethrow when getTaskCollaborators rejects so AssigneeList can show its error state', async () => { + const loadError = new Error('network failure'); + const getTaskCollaborators = jest.fn().mockRejectedValue(loadError); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + render( + , + ); + + await expect(lastTaskItemProps.onLoadAllAssignee?.()).rejects.toThrow('network failure'); + + expect(consoleSpy).toHaveBeenCalledWith( + 'ActivityFeedV2: failed to load assignees for task "task-1"', + loadError, + ); + consoleSpy.mockRestore(); + }); + }); + test('should render version feed items alongside other items', () => { render( { expect(lastTaskProps.onEdit).toBeUndefined(); }); + + test('should call onTaskLoadAllAssignees with the original task when onLoadAllAssignee fires', async () => { + const allAssignees = [{ id: 'user-1', name: 'Creator', status: 'NOT_STARTED' }]; + const onTaskLoadAllAssignees = jest.fn().mockResolvedValue(allAssignees); + render(); + + const result = await lastTaskProps.onLoadAllAssignee?.(); + + expect(onTaskLoadAllAssignees).toHaveBeenCalledWith(mockOriginalTask); + expect(result).toEqual(allAssignees); + }); + + test('should omit onLoadAllAssignee when onTaskLoadAllAssignees is not provided', () => { + render(); + + expect(lastTaskProps.onLoadAllAssignee).toBeUndefined(); + }); }); describe('version rendering', () => { diff --git a/src/elements/content-sidebar/activity-feed-v2/transformers.ts b/src/elements/content-sidebar/activity-feed-v2/transformers.ts index bb511a9cba..2ab8006bdc 100644 --- a/src/elements/content-sidebar/activity-feed-v2/transformers.ts +++ b/src/elements/content-sidebar/activity-feed-v2/transformers.ts @@ -23,7 +23,7 @@ import { convertMillisecondsToTimestamp } from '../../../utils/timestamp'; import type { Annotation, Target } from '../../../common/types/annotations'; import type { AppActivityItem as BUIEAppActivityItem, Comment, FeedItem } from '../../../common/types/feed'; import type { BoxItemVersion, User } from '../../../common/types/core'; -import type { TaskNew } from '../../../common/types/tasks'; +import type { TaskCollabAssignee, TaskNew } from '../../../common/types/tasks'; import type { AnnotationBadgeTargetType, @@ -230,12 +230,11 @@ export const transformAnnotationToMessages = (annotation: Annotation, avatarUrls return [root, ...replies]; }; -export const transformTaskToProps = ( - task: TaskNew, - currentUserId?: string, +export const transformTaskAssignees = ( + entries: TaskCollabAssignee[], avatarUrls?: AvatarUrlMap, -): TaskItemProps => ({ - assignees: (task.assigned_to?.entries ?? []).map(entry => ({ +): TaskItemProps['assignees'] => + entries.map(entry => ({ avatarUrl: resolveAvatarUrl(entry.target?.id, avatarUrls), completedAt: toUnixMs(entry.completed_at), id: entry.target?.id ?? entry.id, @@ -244,7 +243,14 @@ export const transformTaskToProps = ( ? { canDelete: entry.permissions.can_delete, canUpdate: entry.permissions.can_update } : undefined, status: entry.status as TaskItemProps['assignees'][number]['status'], - })), + })); + +export const transformTaskToProps = ( + task: TaskNew, + currentUserId?: string, + avatarUrls?: AvatarUrlMap, +): TaskItemProps => ({ + assignees: transformTaskAssignees(task.assigned_to?.entries ?? [], avatarUrls), author: { avatarUrl: resolveAvatarUrl(task.created_by?.target?.id, avatarUrls), id: task.created_by?.target?.id ?? '',