Skip to content
Open
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 @@ -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';
Expand Down Expand Up @@ -230,6 +230,16 @@ const ActivityFeedV2 = ({

const avatarUrls = useAvatarUrls(feedItems, getAvatarUrl);

// Loads the full assignee list (up to 1000) when the assignee list's "Show more"
// is clicked on a task whose first page (20) did not include all assignees.
const handleTaskLoadAllAssignees = React.useMemo(() => {
if (!getTaskCollaborators) return undefined;
return async (task: TaskNew) => {
const collection = await getTaskCollaborators(task);
return transformTaskAssignees(collection?.entries ?? [], avatarUrls);
};
}, [avatarUrls, getTaskCollaborators]);

const transformedItems: TransformedFeedItem[] = React.useMemo(() => {
if (!feedItems) return [];
return feedItems.reduce<TransformedFeedItem[]>((acc, item) => {
Expand Down Expand Up @@ -507,6 +517,7 @@ const ActivityFeedV2 = ({
onTaskAssignmentUpdate={onTaskAssignmentUpdate}
onTaskDelete={onTaskDelete}
onTaskEdit={onTaskUpdate ? handleTaskEdit : undefined}
onTaskLoadAllAssignees={handleTaskLoadAllAssignees}
onTaskView={onTaskView}
onVersionHistoryClick={onVersionHistoryClick}
timeFormat={timeFormat}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<TaskItemProps['assignees']>;
onTaskView?: (id: string, isCreator: boolean) => void;
onVersionHistoryClick?: (version: { id: string; version_number: number }) => void;
timeFormat: TimeFormat;
Expand Down Expand Up @@ -101,6 +102,7 @@ const FeedItemRow = ({
onTaskAssignmentUpdate,
onTaskDelete,
onTaskEdit,
onTaskLoadAllAssignees,
onTaskView,
onVersionHistoryClick,
timeFormat,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,18 @@ const mockScrollTo = jest.fn<boolean, [string]>(() => true);
type FilterMenuProps = { children?: React.ReactNode; hasActiveFilters?: boolean };
type FilterOptionProps = { checked?: boolean; onCheckedChange?: (checked: boolean) => void };
type RootProps = React.ComponentProps<typeof ActivityFeed.Root>;
type TaskListItemProps = {
hasNextPage?: boolean;
id: string;
onLoadAllAssignee?: () => Promise<unknown>;
};
let lastFilterMenuProps: FilterMenuProps = {};
let lastShowResolvedOptionProps: FilterOptionProps = {};
let lastMentionMeOptionProps: FilterOptionProps = {};
let lastEditorProps: Partial<EditorProps> = {};
let lastRootProps: Partial<RootProps> = {};
let lastTaskModalProps: Partial<TaskModalV2Props> = {};
let lastTaskItemProps: Partial<TaskListItemProps> = {};

jest.mock('../task-modal-v2', () => ({
__esModule: true,
Expand All @@ -55,7 +61,10 @@ jest.mock('@box/activity-feed', () => {
ActivityFeedList.AppActivity = (props: { id: string }) => (
<div data-testid={`app-activity-${props.id}`}>AppActivity</div>
);
ActivityFeedList.Task = (props: { id: string }) => <div data-testid={`task-${props.id}`}>Task</div>;
ActivityFeedList.Task = (props: TaskListItemProps) => {
lastTaskItemProps = props;
return <div data-testid={`task-${props.id}`}>Task</div>;
};
ActivityFeedList.ThreadedAnnotation = (props: { messages?: Array<{ id: string }> }) => (
<div data-testid={`threaded-annotation-${props.messages?.[0]?.id}`}>ThreadedAnnotation</div>
);
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -252,6 +262,87 @@ 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 the assignee list shows "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(
<ActivityFeedV2
currentUser={mockCurrentUser}
feedItems={[taskWithMoreAssignees] as ActivityFeedV2Props['feedItems']}
getTaskCollaborators={getTaskCollaborators}
/>,
);

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

expect(lastTaskItemProps.hasNextPage).toBe(true);
expect(lastTaskItemProps.onLoadAllAssignee).toBeUndefined();
});
});

test('should render version feed items alongside other items', () => {
render(
<ActivityFeedV2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,23 @@ describe('elements/content-sidebar/activity-feed-v2/FeedItemRow', () => {

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(<FeedItemRow {...defaultProps} item={mockTask} onTaskLoadAllAssignees={onTaskLoadAllAssignees} />);

const result = await lastTaskProps.onLoadAllAssignee?.();

expect(onTaskLoadAllAssignees).toHaveBeenCalledWith(mockOriginalTask);
expect(result).toEqual(allAssignees);
});

test('should omit onLoadAllAssignee when onTaskLoadAllAssignees is not provided', () => {
render(<FeedItemRow {...defaultProps} item={mockTask} />);

expect(lastTaskProps.onLoadAllAssignee).toBeUndefined();
});
});

describe('version rendering', () => {
Expand Down
20 changes: 13 additions & 7 deletions src/elements/content-sidebar/activity-feed-v2/transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 ?? '',
Expand Down
Loading