diff --git a/src/elements/content-sidebar/ActivitySidebar.js b/src/elements/content-sidebar/ActivitySidebar.js index 4aa3bb5c6c..8d626c8e99 100644 --- a/src/elements/content-sidebar/ActivitySidebar.js +++ b/src/elements/content-sidebar/ActivitySidebar.js @@ -214,19 +214,27 @@ class ActivitySidebar extends React.PureComponent { } componentWillUnmount() { - // Cancel pending debounces and bump the generation so any in-flight + // Cancel pending debounces and bump the generations so any in-flight // response from getCollaboratorsWithQuery is ignored after unmount. - [this.getMention, this.debouncedFetchMentionCollaborators].forEach(fn => { - if (typeof fn?.cancel === 'function') fn.cancel(); - }); + [this.getMention, this.debouncedFetchMentionCollaborators, this.debouncedFetchApproverCollaborators].forEach( + fn => { + if (typeof fn?.cancel === 'function') fn.cancel(); + }, + ); this.mentionGeneration += 1; - // Resolve any pending getMentionAsync promise with an empty result so - // awaiters (e.g. ActivityFeedV2's fetchUsers) don't hang forever. + this.approverGeneration += 1; + // Resolve any pending promises with an empty result so awaiters + // (e.g. ActivityFeedV2's fetchUsers) don't hang forever. if (this.pendingMentionResolve) { this.pendingMentionResolve([]); } this.pendingMentionResolve = null; this.pendingMentionReject = null; + if (this.pendingApproverResolve) { + this.pendingApproverResolve([]); + } + this.pendingApproverResolve = null; + this.pendingApproverReject = null; } handleAnnotationDelete = ({ id, permissions }: { id: string, permissions: AnnotationPermission }) => { @@ -1078,6 +1086,58 @@ class ActivitySidebar extends React.PureComponent { }); }; + pendingApproverResolve: ?(entries: SelectorItems<>) => void = null; + + pendingApproverReject: ?(error: ElementsXhrError) => void = null; + + approverGeneration: number = 0; + + // Debounced inner fetch. The generation arg guards against in-flight + // responses from superseded requests resolving/rejecting the current + // promise with stale data. + debouncedFetchApproverCollaborators = debounce((searchStr: string, generation: number) => { + const { api, file } = this.props; + api.getFileCollaboratorsAPI(false).getCollaboratorsWithQuery( + file.id, + (collaborators: { entries: SelectorItems<> }) => { + if (generation === this.approverGeneration && this.pendingApproverResolve) { + this.pendingApproverResolve(collaborators.entries); + this.pendingApproverResolve = null; + this.pendingApproverReject = null; + } + }, + (error, code, contextInfo) => { + if (generation !== this.approverGeneration) { + return; + } + this.errorCallback(error, code, contextInfo); + if (this.pendingApproverReject) { + this.pendingApproverReject(error); + this.pendingApproverResolve = null; + this.pendingApproverReject = null; + } + }, + searchStr, + { + includeGroups: true, + respectHiddenCollabs: true, + }, + ); + }, DEFAULT_COLLAB_DEBOUNCE); + + getApproverAsync = (searchStr: string): Promise> => { + if (this.pendingApproverResolve) { + this.pendingApproverResolve([]); + } + this.approverGeneration += 1; + const generation = this.approverGeneration; + return new Promise((resolve, reject) => { + this.pendingApproverResolve = resolve; + this.pendingApproverReject = reject; + this.debouncedFetchApproverCollaborators(searchStr, generation); + }); + }; + /** * Returns feed item based on the item id * @@ -1436,13 +1496,12 @@ class ActivitySidebar extends React.PureComponent { > { }); }); + describe('getApproverAsync()', () => { + test('should get collaborators with groups and respect hidden collaborators and resolve with the entries', async () => { + const wrapper = getWrapper(); + const instance = wrapper.instance(); + fileCollaboratorsAPI.getCollaboratorsWithQuery.mockImplementation((id, successCallback) => { + successCallback(collaborators); + }); + + const search = 'Santa Claus'; + const result = await instance.getApproverAsync(search); + + expect(fileCollaboratorsAPI.getCollaboratorsWithQuery).toHaveBeenCalledTimes(1); + const [fileId, , , searchArg, options] = fileCollaboratorsAPI.getCollaboratorsWithQuery.mock.calls[0]; + expect(fileId).toBe(file.id); + expect(searchArg).toBe(search); + expect(options).toEqual({ + includeGroups: true, + respectHiddenCollabs: true, + }); + expect(result).toEqual(collaborators.entries); + }); + + test('should resolve a superseded call with an empty result', async () => { + const wrapper = getWrapper(); + const instance = wrapper.instance(); + const successCallbacks = []; + fileCollaboratorsAPI.getCollaboratorsWithQuery.mockImplementation((id, successCallback) => { + successCallbacks.push(successCallback); + }); + + const firstPromise = instance.getApproverAsync('first'); + const secondPromise = instance.getApproverAsync('second'); + successCallbacks.forEach(successCallback => successCallback(collaborators)); + + expect(await firstPromise).toEqual([]); + expect(await secondPromise).toEqual(collaborators.entries); + }); + + test('should reject when the fetch errors', async () => { + const wrapper = getWrapper(); + const instance = wrapper.instance(); + const error = new Error('fetch failed'); + fileCollaboratorsAPI.getCollaboratorsWithQuery.mockImplementation((id, successCallback, errorCallback) => { + errorCallback(error, 'error_code', {}); + }); + + await expect(instance.getApproverAsync('query')).rejects.toEqual(error); + }); + }); + describe('getApproverContactsSuccessCallback()', () => { let instance; let wrapper; diff --git a/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx b/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx index 10cc658526..2377385b20 100644 --- a/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx @@ -11,11 +11,13 @@ import noop from 'lodash/noop'; import { FormattedMessage, useIntl } from 'react-intl'; import { ActivityFeed, useActivityFeedScroll } from '@box/activity-feed'; +import type { UserContactType } from '@box/user-selector'; 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 { useAvatarUrls } from './useAvatarUrls'; import { useTimeFormat } from './useTimeFormat'; @@ -42,6 +44,7 @@ const ActivityFeedV2 = ({ currentUser, feedItems, file, + getApproverAsync, getAvatarUrl, getMentionAsync, getTaskCollaborators, @@ -102,6 +105,22 @@ const ActivityFeedV2 = ({ [getMentionAsync], ); + const fetchApprovers = React.useCallback( + async (inputValue: string): Promise => { + const trimmed = inputValue.trim(); + if (!trimmed || !getApproverAsync) { + return []; + } + try { + const entries = await getApproverAsync(trimmed); + return entries.map(mapCollaboratorToUserContact); + } catch { + return []; + } + }, + [getApproverAsync], + ); + const fetchAvatarUrls = React.useCallback( async (userContacts: UserContact[]) => { const urls: Record = {}; @@ -512,7 +531,7 @@ const ActivityFeedV2 = ({ editTask={handleEditTask} error={taskError} fetchAvatarUrls={fetchAvatarUrls} - fetchUsers={fetchUsers} + fetchUsers={fetchApprovers} isOpen={isTaskFormOpen} mode="edit" onClose={handleTaskModalClose} @@ -525,7 +544,7 @@ const ActivityFeedV2 = ({ createTask={handleCreateTask} error={taskError} fetchAvatarUrls={fetchAvatarUrls} - fetchUsers={fetchUsers} + fetchUsers={fetchApprovers} isOpen={isTaskFormOpen} onClose={handleTaskModalClose} onSubmitError={setTaskError} 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 695947aa05..a313210902 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 @@ -1220,6 +1220,40 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => { expect(lastTaskModalProps.isOpen).toBe(false); }); + test('should fetch assignees through getApproverAsync and preserve group entries', async () => { + const getApproverAsync = jest.fn().mockResolvedValue([ + { + id: '7', + item: { email: 'a@b.com', id: '7', login: 'a@b.com', name: 'Alice', type: 'user' }, + name: 'Alice', + }, + { id: '9', item: { id: '9', name: 'Designers', type: 'group' }, name: 'Designers' }, + ]); + const getMentionAsync = jest.fn(); + renderFeed({ createTask: jest.fn(), getApproverAsync, getMentionAsync }); + + const result = await lastTaskModalProps.fetchUsers?.(' des '); + + expect(getApproverAsync).toHaveBeenCalledWith('des'); + expect(getMentionAsync).not.toHaveBeenCalled(); + expect(result).toEqual([ + { email: 'a@b.com', id: 7, name: 'Alice', type: 'user', value: '7' }, + { email: '', id: 9, name: 'Designers', type: 'group', value: '9' }, + ]); + }); + + test('should return empty assignee results when the query is empty, getApproverAsync is missing, or it rejects', async () => { + const getApproverAsync = jest.fn().mockRejectedValue(new Error('API error')); + renderFeed({ createTask: jest.fn(), getApproverAsync }); + expect(await lastTaskModalProps.fetchUsers?.(' ')).toEqual([]); + expect(getApproverAsync).not.toHaveBeenCalled(); + expect(await lastTaskModalProps.fetchUsers?.('query')).toEqual([]); + expect(getApproverAsync).toHaveBeenCalledWith('query'); + + renderFeed({ createTask: jest.fn() }); + expect(await lastTaskModalProps.fetchUsers?.('query')).toEqual([]); + }); + test('should forward createTask arguments and callbacks from the modal to the parent', () => { const createTask = jest.fn(); renderFeed({ createTask }); diff --git a/src/elements/content-sidebar/activity-feed-v2/types.ts b/src/elements/content-sidebar/activity-feed-v2/types.ts index 6a1fe8070f..8a303f7457 100644 --- a/src/elements/content-sidebar/activity-feed-v2/types.ts +++ b/src/elements/content-sidebar/activity-feed-v2/types.ts @@ -8,7 +8,7 @@ import type { AnnotationBadgeTargetType, TextMessageTypeV2 as TextMessageType } 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 { GroupMini, SelectorItem, User, UserMini } from '../../../common/types/core'; import type { TaskAssigneeCollection, TaskCollabStatus, TaskNew, TaskUpdatePayload } from '../../../common/types/tasks'; export type { AppActivityItemProps, TaskItemProps, VersionItemProps } from '@box/activity-feed'; @@ -91,12 +91,11 @@ export type ViewerHandle = { export type ActivityFeedV2Props = { activeFeedEntryId?: string; - approverSelectorContacts?: Array>; createTask?: (...args: Array) => void; currentUser?: User; feedItems?: FeedItems; file?: ActivityFeedV2File; - getApproverWithQuery?: (searchStr: string) => void; + getApproverAsync?: (searchStr: string) => Promise[]>; getAvatarUrl?: GetAvatarUrl; getMentionAsync?: (searchStr: string) => Promise>>; getTaskCollaborators?: (task: TaskNew) => Promise;