Skip to content
Merged
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
75 changes: 67 additions & 8 deletions src/elements/content-sidebar/ActivitySidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,19 +214,27 @@ class ActivitySidebar extends React.PureComponent<Props, State> {
}

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 }) => {
Expand Down Expand Up @@ -1078,6 +1086,58 @@ class ActivitySidebar extends React.PureComponent<Props, State> {
});
};

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<SelectorItems<>> => {
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
*
Expand Down Expand Up @@ -1436,13 +1496,12 @@ class ActivitySidebar extends React.PureComponent<Props, State> {
>
<ActivityFeedV2
activeFeedEntryId={activeFeedEntryId}
approverSelectorContacts={approverSelectorContacts}
createTask={this.createTask}
currentUser={currentUser}
feedItems={this.getFilteredFeedItems()}
getViewer={this.props.getViewer}
file={file}
getApproverWithQuery={this.getApprover}
getApproverAsync={this.getApproverAsync}
getAvatarUrl={this.getAvatarUrl}
getMentionAsync={this.getMentionAsync}
getTaskCollaborators={this.getTaskCollaborators}
Expand Down
50 changes: 50 additions & 0 deletions src/elements/content-sidebar/__tests__/ActivitySidebar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,56 @@ describe('elements/content-sidebar/ActivitySidebar', () => {
});
});

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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -42,6 +44,7 @@ const ActivityFeedV2 = ({
currentUser,
feedItems,
file,
getApproverAsync,
getAvatarUrl,
getMentionAsync,
getTaskCollaborators,
Expand Down Expand Up @@ -102,6 +105,22 @@ const ActivityFeedV2 = ({
[getMentionAsync],
);

const fetchApprovers = React.useCallback(
async (inputValue: string): Promise<UserContactType[]> => {
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<string, string> = {};
Expand Down Expand Up @@ -512,7 +531,7 @@ const ActivityFeedV2 = ({
editTask={handleEditTask}
error={taskError}
fetchAvatarUrls={fetchAvatarUrls}
fetchUsers={fetchUsers}
fetchUsers={fetchApprovers}
isOpen={isTaskFormOpen}
mode="edit"
onClose={handleTaskModalClose}
Expand All @@ -525,7 +544,7 @@ const ActivityFeedV2 = ({
createTask={handleCreateTask}
error={taskError}
fetchAvatarUrls={fetchAvatarUrls}
fetchUsers={fetchUsers}
fetchUsers={fetchApprovers}
isOpen={isTaskFormOpen}
onClose={handleTaskModalClose}
onSubmitError={setTaskError}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
5 changes: 2 additions & 3 deletions src/elements/content-sidebar/activity-feed-v2/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -91,12 +91,11 @@ export type ViewerHandle = {

export type ActivityFeedV2Props = {
activeFeedEntryId?: string;
approverSelectorContacts?: Array<Record<string, unknown>>;
createTask?: (...args: Array<unknown>) => void;
currentUser?: User;
feedItems?: FeedItems;
file?: ActivityFeedV2File;
getApproverWithQuery?: (searchStr: string) => void;
getApproverAsync?: (searchStr: string) => Promise<SelectorItem<UserMini | GroupMini>[]>;
getAvatarUrl?: GetAvatarUrl;
getMentionAsync?: (searchStr: string) => Promise<Array<Record<string, unknown>>>;
getTaskCollaborators?: (task: TaskNew) => Promise<TaskAssigneeCollection>;
Expand Down
Loading