From 451f95be381a5dc7bbbb6ba0f9e135352b265312 Mon Sep 17 00:00:00 2001 From: Jackie Jou Date: Thu, 21 May 2026 08:40:02 -0700 Subject: [PATCH 1/2] feat(activity-feed-v2): Wire task action and edit callbacks - Approve/Reject/Complete via onTaskAssignmentUpdate, gated on assignment + can_update + NOT_STARTED so the vendor TaskItem hides buttons the user cannot act on - Edit via onTaskUpdate, refetching assignees through getTaskCollaborators when next_marker is set so the form sees the full list - hasNextPage on the task transformer - Tighten onTaskUpdate.onError to (error, code) --- .../content-sidebar/ActivitySidebar.js | 16 ++ .../activity-feed-v2/ActivityFeedV2.tsx | 73 +++++++-- .../activity-feed-v2/FeedItemRow.tsx | 34 ++++- .../__tests__/FeedItemRow.test.tsx | 142 +++++++++++++++++- .../__tests__/transformers.test.ts | 14 ++ .../activity-feed-v2/transformers.ts | 1 + .../content-sidebar/activity-feed-v2/types.ts | 9 +- 7 files changed, 273 insertions(+), 16 deletions(-) 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..96c2f9b288 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,41 @@ 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) { + if (!getTaskCollaborators) { + // eslint-disable-next-line no-console + console.error(`ActivityFeedV2: missing getTaskCollaborators for paginated task "${task.id}"`); + return; + } + 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); + return; + } + } + 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 +314,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 +332,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 730aa91cfb..f353294985 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 dcdcef7062..ee500a70f0 100644 --- a/src/elements/content-sidebar/activity-feed-v2/transformers.ts +++ b/src/elements/content-sidebar/activity-feed-v2/transformers.ts @@ -192,6 +192,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; From c06ad6d57dd9eab1ade236148b7a166dc00ce470 Mon Sep 17 00:00:00 2001 From: Jackie Jou Date: Thu, 21 May 2026 09:00:53 -0700 Subject: [PATCH 2/2] fix(activity-feed-v2): Open task edit modal on assignee load failure Pre-fetching paginated assignees is best-effort enrichment. When the fetch fails or the consumer did not provide getTaskCollaborators, open the modal anyway with whatever assignees the activity payload returned instead of silently swallowing the click. TaskForm.getRemovedAssignees only diffs against loaded assignees, so unloaded ones survive submit. --- .../content-sidebar/activity-feed-v2/ActivityFeedV2.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx b/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx index 96c2f9b288..1f93b1675e 100644 --- a/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx @@ -168,18 +168,12 @@ const ActivityFeedV2 = ({ const handleTaskEdit = React.useCallback( async (task: TaskNew) => { let fullAssignees = task.assigned_to; - if (task.assigned_to?.next_marker) { - if (!getTaskCollaborators) { - // eslint-disable-next-line no-console - console.error(`ActivityFeedV2: missing getTaskCollaborators for paginated task "${task.id}"`); - return; - } + 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); - return; } } setEditingTask(task);