diff --git a/src/elements/content-sidebar/ActivitySidebar.js b/src/elements/content-sidebar/ActivitySidebar.js index a102651f9b..b54c62a817 100644 --- a/src/elements/content-sidebar/ActivitySidebar.js +++ b/src/elements/content-sidebar/ActivitySidebar.js @@ -48,6 +48,7 @@ import { METRIC_TYPE_UAA_PARITY_METRIC, } from '../../constants'; import type { + TaskAssigneeCollection, TaskCompletionRule, TaskType, TaskNew, @@ -420,6 +421,18 @@ class ActivitySidebar extends React.PureComponent { this.fetchFeedItems(); }; + getTaskCollaborators = (task: TaskNew): Promise => { + const { api, file } = this.props; + return new Promise((resolve, reject) => { + api.getTaskCollaboratorsAPI(false).getTaskCollaborators({ + errorCallback: reject, + file: { id: file.id }, + successCallback: resolve, + task: { id: task.id }, + }); + }); + }; + /** * Deletes a comment via the API. * @@ -1410,6 +1423,7 @@ class ActivitySidebar extends React.PureComponent { getApproverWithQuery={this.getApprover} getAvatarUrl={this.getAvatarUrl} getMentionAsync={this.getMentionAsync} + getTaskCollaborators={this.getTaskCollaborators} hasTasks={this.props.hasTasks} isDisabled={isDisabled} onAnnotationCopyLink={onAnnotationCopyLink} @@ -1427,7 +1441,9 @@ class ActivitySidebar extends React.PureComponent { } onShowOnlyMentionsMeChange={onShowOnlyMentionsMeChange} onShowResolvedChange={onShowResolvedChange} + onTaskAssignmentUpdate={this.updateTaskAssignment} onTaskDelete={this.deleteTask} + onTaskUpdate={this.updateTask} onTaskView={onTaskView} onVersionHistoryClick={onVersionHistoryClick} showOnlyMentionsMe={showOnlyMentionsMe} diff --git a/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx b/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx index ad78f8235f..1f93b1675e 100644 --- a/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx @@ -19,8 +19,9 @@ import FeedItemRow from './FeedItemRow'; import { transformFeedItem } from './transformers'; import type { ActivityFeedV2Props, TransformedFeedItem, UserContact } from './types'; +import type { TaskAssigneeCollection, TaskNew } from '../../../common/types/tasks'; -import { TASK_COMPLETION_RULE_ALL, TASK_TYPE_APPROVAL } from '../../../constants'; +import { TASK_COMPLETION_RULE_ALL, TASK_EDIT_MODE_EDIT, TASK_TYPE_APPROVAL } from '../../../constants'; import commonMessages from '../../common/messages'; import messages from '../messages'; @@ -36,6 +37,7 @@ const ActivityFeedV2 = ({ getApproverWithQuery, getAvatarUrl, getMentionAsync, + getTaskCollaborators, hasTasks = true, isDisabled = false, onAnnotationCopyLink, @@ -51,7 +53,9 @@ const ActivityFeedV2 = ({ onReplyUpdate, onShowOnlyMentionsMeChange, onShowResolvedChange, + onTaskAssignmentUpdate, onTaskDelete, + onTaskUpdate, onTaskView, onVersionHistoryClick, showOnlyMentionsMe: showOnlyMentionsMeProp, @@ -151,12 +155,35 @@ const ActivityFeedV2 = ({ const [isTaskFormOpen, setIsTaskFormOpen] = React.useState(false); const [taskType, setTaskType] = React.useState(TASK_TYPE_APPROVAL); const [taskError, setTaskError] = React.useState(null); + const [editingTask, setEditingTask] = React.useState(null); + const [editingAssignees, setEditingAssignees] = React.useState(null); const handleTaskModalClose = React.useCallback(() => { setIsTaskFormOpen(false); setTaskError(null); + setEditingTask(null); + setEditingAssignees(null); }, []); + const handleTaskEdit = React.useCallback( + async (task: TaskNew) => { + let fullAssignees = task.assigned_to; + if (task.assigned_to?.next_marker && getTaskCollaborators) { + try { + fullAssignees = await getTaskCollaborators(task); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`ActivityFeedV2: failed to load assignees for task "${task.id}"`, error); + } + } + setEditingTask(task); + setEditingAssignees(fullAssignees); + setTaskType(task.task_type); + setIsTaskFormOpen(true); + }, + [getTaskCollaborators], + ); + const transformedItems: TransformedFeedItem[] = React.useMemo(() => { if (!feedItems) return []; return feedItems.reduce((acc, item) => { @@ -281,7 +308,9 @@ const ActivityFeedV2 = ({ onCommentUpdate={onCommentUpdate} onReplyCreate={onReplyCreate} onReplyUpdate={onReplyUpdate} + onTaskAssignmentUpdate={onTaskAssignmentUpdate} onTaskDelete={onTaskDelete} + onTaskEdit={onTaskUpdate ? handleTaskEdit : undefined} onTaskView={onTaskView} onVersionHistoryClick={onVersionHistoryClick} userSelectorProps={userSelectorProps} @@ -297,21 +326,37 @@ const ActivityFeedV2 = ({ /> diff --git a/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx b/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx index b4d82ed8ee..ef0d8f7c2f 100644 --- a/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx @@ -10,14 +10,21 @@ import { ActivityFeed } from '@box/activity-feed'; import type { Annotation, AnnotationPermission } from '../../../common/types/annotations'; import type { BoxCommentPermission, CommentFeedItemType, FeedItemStatus } from '../../../common/types/feed'; -import type { TaskNew } from '../../../common/types/tasks'; +import type { TaskCollabStatus, TaskNew } from '../../../common/types/tasks'; import { dispatchReplyEdit, logEditError, serializeEditorContent } from './helpers'; import { annotationTargetToBadge } from './transformers'; import type { OnReplyUpdate, TransformedFeedItem, UserSelectorProps } from './types'; -import { FEED_ITEM_TYPE_ANNOTATION, FEED_ITEM_TYPE_COMMENT } from '../../../constants'; +import { + FEED_ITEM_TYPE_ANNOTATION, + FEED_ITEM_TYPE_COMMENT, + TASK_NEW_APPROVED, + TASK_NEW_COMPLETED, + TASK_NEW_NOT_STARTED, + TASK_NEW_REJECTED, +} from '../../../constants'; type FeedItemRowProps = { currentUserId?: string; @@ -45,7 +52,9 @@ type FeedItemRowProps = { ) => void; onReplyCreate?: (parentId: string, parentType: CommentFeedItemType, text: string) => void; onReplyUpdate?: OnReplyUpdate; + onTaskAssignmentUpdate?: (taskId: string, taskAssignmentId: string, status: TaskCollabStatus) => void; onTaskDelete?: (task: TaskNew) => void; + onTaskEdit?: (task: TaskNew) => void; onTaskView?: (id: string, isCreator: boolean) => void; onVersionHistoryClick?: (version: { id: string; version_number: number }) => void; userSelectorProps: UserSelectorProps; @@ -79,7 +88,9 @@ const FeedItemRow = ({ onCommentUpdate, onReplyCreate, onReplyUpdate, + onTaskAssignmentUpdate, onTaskDelete, + onTaskEdit, onTaskView, onVersionHistoryClick, userSelectorProps, @@ -190,13 +201,29 @@ const FeedItemRow = ({ ); } - case 'task': + case 'task': { + const currentUserAssignment = currentUserId + ? item.originalTask.assigned_to?.entries?.find(entry => entry.target?.id === currentUserId) + : undefined; + const canActOnAssignment = + !!onTaskAssignmentUpdate && + !!currentUserAssignment && + currentUserAssignment.permissions?.can_update === true && + currentUserAssignment.status === TASK_NEW_NOT_STARTED; + const handleAssignmentUpdate = (status: TaskCollabStatus) => { + if (isDisabled || !canActOnAssignment) return; + onTaskAssignmentUpdate?.(item.id, currentUserAssignment!.id, status); + }; return ( handleAssignmentUpdate(TASK_NEW_APPROVED) : undefined} + onComplete={canActOnAssignment ? () => handleAssignmentUpdate(TASK_NEW_COMPLETED) : undefined} onDelete={onTaskDelete ? () => onTaskDelete(item.originalTask) : undefined} + onEdit={onTaskEdit ? () => onTaskEdit(item.originalTask) : undefined} + onReject={canActOnAssignment ? () => handleAssignmentUpdate(TASK_NEW_REJECTED) : undefined} onView={ onTaskView ? (taskId: string) => onTaskView(taskId, currentUserId === item.props.author.id) @@ -204,6 +231,7 @@ const FeedItemRow = ({ } /> ); + } case 'version': return ( diff --git a/src/elements/content-sidebar/activity-feed-v2/__tests__/FeedItemRow.test.tsx b/src/elements/content-sidebar/activity-feed-v2/__tests__/FeedItemRow.test.tsx index 1c97dbb592..fea1cb3d5d 100644 --- a/src/elements/content-sidebar/activity-feed-v2/__tests__/FeedItemRow.test.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/__tests__/FeedItemRow.test.tsx @@ -127,8 +127,17 @@ const mockAnnotation: TransformedAnnotationItem = { type: 'annotation', }; +const mockAssignment = { + id: 'assignment-1', + permissions: { can_delete: true, can_update: true }, + role: 'ASSIGNEE', + status: 'NOT_STARTED', + target: { id: 'user-1', name: 'Creator', type: 'user' }, + type: 'task_collaborator', +}; + const mockOriginalTask = { - assigned_to: { entries: [], limit: 20, next_marker: null }, + assigned_to: { entries: [mockAssignment], limit: 20, next_marker: null }, completion_rule: 'ALL_ASSIGNEES', created_at: '2024-01-01T00:00:00Z', created_by: { @@ -587,6 +596,137 @@ describe('elements/content-sidebar/activity-feed-v2/FeedItemRow', () => { expect(onTaskView).toHaveBeenCalledWith('task-1', false); }); + + test.each([ + ['onApprove', 'APPROVED'], + ['onComplete', 'COMPLETED'], + ['onReject', 'REJECTED'], + ] as const)( + 'should call onTaskAssignmentUpdate with the current user assignment id when %s fires', + (callback, expectedStatus) => { + const onTaskAssignmentUpdate = jest.fn(); + render( + , + ); + + lastTaskProps[callback]?.('task-1'); + + expect(onTaskAssignmentUpdate).toHaveBeenCalledWith('task-1', 'assignment-1', expectedStatus); + }, + ); + + test('should omit onApprove/onComplete/onReject when current user is not assigned', () => { + const onTaskAssignmentUpdate = jest.fn(); + render( + , + ); + + expect(lastTaskProps.onApprove).toBeUndefined(); + expect(lastTaskProps.onComplete).toBeUndefined(); + expect(lastTaskProps.onReject).toBeUndefined(); + }); + + test('should omit onApprove/onComplete/onReject when assignment lacks can_update permission', () => { + const taskWithoutUpdatePerm: TransformedFeedItem = { + ...mockTask, + originalTask: { + ...mockOriginalTask, + assigned_to: { + ...mockOriginalTask.assigned_to, + entries: [{ ...mockAssignment, permissions: { can_delete: true, can_update: false } }], + }, + } as unknown as TaskNew, + }; + render( + , + ); + + expect(lastTaskProps.onApprove).toBeUndefined(); + expect(lastTaskProps.onComplete).toBeUndefined(); + expect(lastTaskProps.onReject).toBeUndefined(); + }); + + test('should omit onApprove/onComplete/onReject when assignment status is not NOT_STARTED', () => { + const completedTask: TransformedFeedItem = { + ...mockTask, + originalTask: { + ...mockOriginalTask, + assigned_to: { + ...mockOriginalTask.assigned_to, + entries: [{ ...mockAssignment, status: 'COMPLETED' }], + }, + } as unknown as TaskNew, + }; + render( + , + ); + + expect(lastTaskProps.onApprove).toBeUndefined(); + expect(lastTaskProps.onComplete).toBeUndefined(); + expect(lastTaskProps.onReject).toBeUndefined(); + }); + + test('should not call onTaskAssignmentUpdate when isDisabled', () => { + const onTaskAssignmentUpdate = jest.fn(); + render( + , + ); + + lastTaskProps.onApprove?.('task-1'); + lastTaskProps.onComplete?.('task-1'); + lastTaskProps.onReject?.('task-1'); + + expect(onTaskAssignmentUpdate).not.toHaveBeenCalled(); + }); + + test('should omit onApprove/onComplete/onReject when onTaskAssignmentUpdate is not provided', () => { + render(); + + expect(lastTaskProps.onApprove).toBeUndefined(); + expect(lastTaskProps.onComplete).toBeUndefined(); + expect(lastTaskProps.onReject).toBeUndefined(); + }); + + test('should call onTaskEdit with the original task when edit fires', () => { + const onTaskEdit = jest.fn(); + render(); + + lastTaskProps.onEdit?.('task-1'); + + expect(onTaskEdit).toHaveBeenCalledWith(mockOriginalTask); + }); + + test('should omit onEdit when onTaskEdit is not provided', () => { + render(); + + expect(lastTaskProps.onEdit).toBeUndefined(); + }); }); describe('version rendering', () => { diff --git a/src/elements/content-sidebar/activity-feed-v2/__tests__/transformers.test.ts b/src/elements/content-sidebar/activity-feed-v2/__tests__/transformers.test.ts index 8fb8c7841a..e63d2756f2 100644 --- a/src/elements/content-sidebar/activity-feed-v2/__tests__/transformers.test.ts +++ b/src/elements/content-sidebar/activity-feed-v2/__tests__/transformers.test.ts @@ -367,6 +367,20 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => { const result = transformTaskToProps(approvalTask as unknown as TaskNew); expect(result.taskType).toBe('APPROVAL'); }); + + test('should set hasNextPage=false when assigned_to.next_marker is null', () => { + const result = transformTaskToProps(mockTask as unknown as TaskNew); + expect(result.hasNextPage).toBe(false); + }); + + test('should set hasNextPage=true when assigned_to.next_marker is present', () => { + const taskWithMore = { + ...mockTask, + assigned_to: { ...mockTask.assigned_to, next_marker: 'next-cursor' }, + }; + const result = transformTaskToProps(taskWithMore as unknown as TaskNew); + expect(result.hasNextPage).toBe(true); + }); }); describe('transformVersionToProps()', () => { diff --git a/src/elements/content-sidebar/activity-feed-v2/transformers.ts b/src/elements/content-sidebar/activity-feed-v2/transformers.ts index 74295748c6..c329a09c53 100644 --- a/src/elements/content-sidebar/activity-feed-v2/transformers.ts +++ b/src/elements/content-sidebar/activity-feed-v2/transformers.ts @@ -201,6 +201,7 @@ export const transformTaskToProps = (task: TaskNew, currentUserId?: string): Tas currentUserId, description: task.description, dueDate: toUnixMs(task.due_at), + hasNextPage: Boolean(task.assigned_to?.next_marker), id: task.id, permissions: { canCreateTaskCollaborator: task.permissions?.can_create_task_collaborator ?? false, diff --git a/src/elements/content-sidebar/activity-feed-v2/types.ts b/src/elements/content-sidebar/activity-feed-v2/types.ts index f7047fcc33..0b34ceb4d1 100644 --- a/src/elements/content-sidebar/activity-feed-v2/types.ts +++ b/src/elements/content-sidebar/activity-feed-v2/types.ts @@ -9,7 +9,7 @@ import type { TextMessageTypeV2 as TextMessageType } from '@box/threaded-annotat import type { Annotation, AnnotationPermission } from '../../../common/types/annotations'; import type { BoxCommentPermission, CommentFeedItemType, FeedItems, FeedItemStatus } from '../../../common/types/feed'; import type { User } from '../../../common/types/core'; -import type { TaskNew } from '../../../common/types/tasks'; +import type { TaskAssigneeCollection, TaskCollabStatus, TaskNew, TaskUpdatePayload } from '../../../common/types/tasks'; export type { AppActivityItemProps, TaskItemProps, VersionItemProps } from '@box/activity-feed'; export type { AnnotationBadgeTargetType } from '@box/threaded-annotations'; @@ -77,6 +77,7 @@ export type ActivityFeedV2Props = { getApproverWithQuery?: (searchStr: string) => void; getAvatarUrl?: (userId: string) => Promise; getMentionAsync?: (searchStr: string) => Promise>>; + getTaskCollaborators?: (task: TaskNew) => Promise; hasTasks?: boolean; isDisabled?: boolean; onAnnotationCopyLink?: (params: { annotationId: string; fileVersionId: string }) => void; @@ -104,7 +105,13 @@ export type ActivityFeedV2Props = { onReplyUpdate?: OnReplyUpdate; onShowOnlyMentionsMeChange?: (checked: boolean) => void; onShowResolvedChange?: (checked: boolean) => void; + onTaskAssignmentUpdate?: (taskId: string, taskAssignmentId: string, status: TaskCollabStatus) => void; onTaskDelete?: (task: TaskNew) => void; + onTaskUpdate?: ( + task: TaskUpdatePayload, + onSuccess?: () => void, + onError?: (error: unknown, code?: string) => void, + ) => void; onTaskView?: (id: string, isCreator: boolean) => void; onVersionHistoryClick?: (version: { id: string; version_number: number }) => void; showOnlyMentionsMe?: boolean;