UNPKG

box-ui-elements-test

Version:
1,393 lines (1,262 loc) 84.2 kB
/** * @flow * @file Helper for activity feed API's * @author Box */ import noop from 'lodash/noop'; import uniqueId from 'lodash/uniqueId'; import type { MessageDescriptor } from 'react-intl'; import { getBadItemError, getBadUserError, getMissingItemTextOrStatus, isUserCorrectableError } from '../utils/error'; import commonMessages from '../elements/common/messages'; import messages from './messages'; import { sortFeedItems } from '../utils/sorter'; import { FEED_FILE_VERSIONS_FIELDS_TO_FETCH } from '../utils/fields'; import Base from './Base'; import AnnotationsAPI from './Annotations'; import CommentsAPI from './Comments'; import ThreadedCommentsAPI from './ThreadedComments'; import FileActivitiesAPI from './FileActivities'; import VersionsAPI from './Versions'; import TasksNewAPI from './tasks/TasksNew'; import GroupsAPI from './Groups'; import TaskCollaboratorsAPI from './tasks/TaskCollaborators'; import TaskLinksAPI from './tasks/TaskLinks'; import AppActivityAPI from './AppActivity'; import { ACTION_TYPE_CREATED, ACTION_TYPE_RESTORED, ACTION_TYPE_TRASHED, ERROR_CODE_CREATE_TASK, ERROR_CODE_UPDATE_TASK, ERROR_CODE_GROUP_EXCEEDS_LIMIT, FEED_ITEM_TYPE_ANNOTATION, FEED_ITEM_TYPE_COMMENT, FEED_ITEM_TYPE_TASK, FEED_ITEM_TYPE_VERSION, FILE_ACTIVITY_TYPE_ANNOTATION, FILE_ACTIVITY_TYPE_APP_ACTIVITY, FILE_ACTIVITY_TYPE_COMMENT, FILE_ACTIVITY_TYPE_TASK, FILE_ACTIVITY_TYPE_VERSION, HTTP_STATUS_CODE_CONFLICT, IS_ERROR_DISPLAYED, PERMISSION_CAN_VIEW_ANNOTATIONS, PERMISSION_CAN_COMMENT, TASK_NEW_APPROVED, TASK_NEW_COMPLETED, TASK_NEW_REJECTED, TASK_NEW_NOT_STARTED, TYPED_ID_FEED_PREFIX, TASK_MAX_GROUP_ASSIGNEES, } from '../constants'; import type { TaskCompletionRule, TaskCollabAssignee, TaskCollabStatus, TaskLink, TaskNew, TaskType, TaskPayload, TaskUpdatePayload, } from '../common/types/tasks'; import type { ElementsXhrError, ErrorResponseData, APIOptions } from '../common/types/api'; import type { SelectorItems, SelectorItem, UserMini, GroupMini, BoxItem, BoxItemPermission, BoxItemVersion, FileVersions, User, } from '../common/types/core'; import type { Annotation, AnnotationPermission, Annotations, AppActivityItems, BoxCommentPermission, Comment, CommentFeedItemType, Comments, FeedItem, FeedItems, FeedItemStatus, FileActivity, FileActivityTypes, Task, Tasks, ThreadedComments as ThreadedCommentsType, } from '../common/types/feed'; const TASK_NEW_INITIAL_STATUS = TASK_NEW_NOT_STARTED; type FeedItemsCache = { errors: ErrorResponseData[], items: FeedItems, }; type ErrorCallback = (e: ElementsXhrError, code: string, contextInfo?: Object) => void; const getItemWithFilteredReplies = <T: { replies?: Array<Comment> }>(item: T, replyId: string): T => { const { replies = [], ...rest } = item; return { replies: replies.filter(({ id }) => id !== replyId), ...rest }; }; const getItemWithPendingReply = <T: { replies?: Array<Comment> }>(item: T, reply: Comment): T => { const { replies = [], ...rest } = item; return { replies: [...replies, reply], ...rest }; }; const parseReplies = (replies: Comment[]): Comment[] => { const parsedReplies = [...replies]; return parsedReplies.map(reply => { return { ...reply, tagged_message: reply.tagged_message || reply.message || '' }; }); }; export const getParsedFileActivitiesResponse = (response?: { entries: FileActivity[] }) => { if (!response || !response.entries || !response.entries.length) { return []; } const data = response.entries; const parsedData: Array<Object> = data .map(item => { if (!item.source) { return null; } const source = { ...item.source }; switch (item.activity_type) { case FILE_ACTIVITY_TYPE_TASK: { const taskItem = { ...source[FILE_ACTIVITY_TYPE_TASK] }; // UAA follows a lowercased enum naming convention, convert to uppercase to align with task api if (taskItem.assigned_to?.entries) { const assignedToEntries = taskItem.assigned_to.entries.map(entry => { const assignedToEntry = { ...entry }; assignedToEntry.role = entry.role.toUpperCase(); assignedToEntry.status = entry.status.toUpperCase(); return assignedToEntry; }); // $FlowFixMe Using the toUpperCase method makes Flow assume role and status is a string type, which is incompatible with string literal taskItem.assigned_to.entries = assignedToEntries; } if (taskItem.completion_rule) { taskItem.completion_rule = taskItem.completion_rule.toUpperCase(); } if (taskItem.status) { taskItem.status = taskItem.status.toUpperCase(); } if (taskItem.task_type) { taskItem.task_type = taskItem.task_type.toUpperCase(); } // $FlowFixMe File Activities only returns a created_by user, Flow type fix is needed taskItem.created_by = { target: taskItem.created_by }; return taskItem; } case FILE_ACTIVITY_TYPE_COMMENT: { const commentItem = { ...source[FILE_ACTIVITY_TYPE_COMMENT] }; if (commentItem.replies && commentItem.replies.length) { const replies = parseReplies(commentItem.replies); commentItem.replies = replies; } commentItem.tagged_message = commentItem.tagged_message || commentItem.message || ''; return commentItem; } case FILE_ACTIVITY_TYPE_ANNOTATION: { const annotationItem = { ...source[FILE_ACTIVITY_TYPE_ANNOTATION] }; if (annotationItem.replies && annotationItem.replies.length) { const replies = parseReplies(annotationItem.replies); annotationItem.replies = replies; } return annotationItem; } case FILE_ACTIVITY_TYPE_APP_ACTIVITY: { const appActivityItem = { ...source[FILE_ACTIVITY_TYPE_APP_ACTIVITY] }; appActivityItem.created_at = appActivityItem.occurred_at; return appActivityItem; } case FILE_ACTIVITY_TYPE_VERSION: { const versionsItem = { ...source[FILE_ACTIVITY_TYPE_VERSION] }; versionsItem.type = FEED_ITEM_TYPE_VERSION; if (versionsItem.action_by) { const collaborators = {}; if (versionsItem.action_by.length === 1) { versionsItem.uploader_display_name = versionsItem.action_by[0].name; } versionsItem.action_by.map(collaborator => { collaborators[collaborator.id] = { ...collaborator }; return collaborator; }); versionsItem.collaborators = collaborators; } if (versionsItem.end?.number) { versionsItem.version_end = versionsItem.end.number; versionsItem.id = versionsItem.end.id; } if (versionsItem.start?.number) { versionsItem.version_start = versionsItem.start.number; } if (versionsItem.version_start === versionsItem.version_end) { versionsItem.version_number = versionsItem.version_start; if ( versionsItem.action_type === ACTION_TYPE_CREATED && versionsItem.start?.created_at && versionsItem.start?.created_by ) { versionsItem.modified_at = versionsItem.start.created_at; versionsItem.modified_by = { ...versionsItem.start.created_by }; } if ( versionsItem.action_type === ACTION_TYPE_TRASHED && versionsItem.start?.trashed_at && versionsItem.start?.trashed_by ) { versionsItem.trashed_at = versionsItem.start.trashed_at; versionsItem.trashed_by = { ...versionsItem.start.trashed_by }; } if ( versionsItem.action_type === ACTION_TYPE_RESTORED && versionsItem.start?.restored_at && versionsItem.start?.restored_by ) { versionsItem.restored_at = versionsItem.start.restored_at; versionsItem.restored_by = { ...versionsItem.start.restored_by }; } } return versionsItem; } default: { return null; } } }) .filter(item => !!item) .reverse(); return parsedData; }; class Feed extends Base { /** * @property {AnnotationsAPI} */ annotationsAPI: AnnotationsAPI; /** * @property {VersionsAPI} */ versionsAPI: VersionsAPI; /** * @property {CommentsAPI} */ commentsAPI: CommentsAPI; /** * @property {AppActivityAPI} */ appActivityAPI: AppActivityAPI; /** * @property {TasksNewAPI} */ tasksNewAPI: TasksNewAPI; /** * @property {TaskCollaboratorsAPI} */ taskCollaboratorsAPI: TaskCollaboratorsAPI[]; /** * @property {TaskLinksAPI} */ taskLinksAPI: TaskLinksAPI[]; /** * @property {ThreadedCommentsAPI} */ threadedCommentsAPI: ThreadedCommentsAPI; /** * @property {FileActivitiesAPI} */ fileActivitiesAPI: FileActivitiesAPI; /** * @property {BoxItem} */ file: BoxItem; /** * @property {ElementsXhrError} */ errors: ElementsXhrError[]; constructor(options: APIOptions) { super(options); this.taskCollaboratorsAPI = []; this.taskLinksAPI = []; this.errors = []; } /** * Creates pending card on create_start action, then updates card on next call * @param {BoxItem} file - The file to which the annotation is assigned * @param {Object} currentUser - the user who performed the action * @param {Annotation} annotation - the current annotation to be created * @param {string} id - unique id for the incoming annotation * @param {boolean} isPending - indicates the current creation process of the annotation */ addAnnotation(file: BoxItem, currentUser: User, annotation: Annotation, id: string, isPending: boolean): void { if (!file.id) { throw getBadItemError(); } this.file = file; // Add the pending interstitial card if (isPending) { const newAnnotation = { ...annotation, created_by: currentUser, id, type: FEED_ITEM_TYPE_ANNOTATION, }; this.addPendingItem(this.file.id, currentUser, newAnnotation); return; } // Create action has completed, so update the existing pending item this.updateFeedItem({ ...annotation, isPending: false }, id); } updateAnnotation = ( file: BoxItem, annotationId: string, text?: string, status?: FeedItemStatus, permissions: AnnotationPermission, successCallback: (annotation: Annotation) => void, errorCallback: ErrorCallback, ): void => { if (!file.id) { throw getBadItemError(); } if (!text && !status) { throw getMissingItemTextOrStatus(); } this.annotationsAPI = new AnnotationsAPI(this.options); this.file = file; this.errorCallback = errorCallback; const feedItemChanges = {}; if (text) { feedItemChanges.message = text; } if (status) { feedItemChanges.status = status; } this.updateFeedItem({ ...feedItemChanges, isPending: true }, annotationId); this.annotationsAPI.updateAnnotation( this.file.id, annotationId, permissions, feedItemChanges, (annotation: Annotation) => { const { replies, total_reply_count, ...annotationBase } = annotation; this.updateFeedItem( { // Do not update replies and total_reply_count props as their current values are not included in the response ...annotationBase, isPending: false, }, annotationId, ); if (!this.isDestroyed()) { successCallback(annotation); } }, (e: ErrorResponseData, code: string) => { this.updateCommentErrorCallback(e, code, annotationId); }, ); }; /** * Error callback for updating a comment * * @param {ElementsXhrError} e - the error returned by the API * @param {string} code - the error code * @param {string} id - the id of either an annotation or comment * @return {void} */ updateCommentErrorCallback = (e: ElementsXhrError, code: string, id: string) => { this.updateFeedItem(this.createFeedError(messages.commentUpdateErrorMessage), id); this.feedErrorCallback(true, e, code); }; /** * Error callback for updating a reply * * @param {ElementsXhrError} error - the error returned by the API * @param {string} code - the error code * @param {string} id - the id of the reply (comment) * @param {string} parentId - the id of either the parent item (an annotation or comment) * @return {void} */ updateReplyErrorCallback = (error: ElementsXhrError, code: string, id: string, parentId: string) => { this.updateReplyItem(this.createFeedError(messages.commentUpdateErrorMessage), parentId, id); this.feedErrorCallback(true, error, code); }; /** * Error callback for fetching replies * * @param {ElementsXhrError} error - the error returned by the API * @param {string} code - the error code * @param {string} id - the id of either an annotation or comment * @return {void} */ fetchRepliesErrorCallback = (error: ElementsXhrError, code: string, id: string) => { this.updateFeedItem(this.createFeedError(messages.repliesFetchErrorMessage), id); this.feedErrorCallback(true, error, code); }; deleteAnnotation = ( file: BoxItem, annotationId: string, permissions: AnnotationPermission, successCallBack: Function, errorCallback: Function, ): void => { this.annotationsAPI = new AnnotationsAPI(this.options); if (!file.id) { throw getBadItemError(); } this.file = file; this.errorCallback = errorCallback; this.updateFeedItem({ isPending: true }, annotationId); this.annotationsAPI.deleteAnnotation( this.file.id, annotationId, permissions, this.deleteFeedItem.bind(this, annotationId, successCallBack), (error: ElementsXhrError, code: string) => { // Reusing comment error handler since annotations are treated as comments to user this.deleteCommentErrorCallback(error, code, annotationId); }, ); }; /** * Creates a key for the cache * * @param {string} id folder id * @return {string} key */ getCacheKey(id: string): string { return `${TYPED_ID_FEED_PREFIX}${id}`; } /** * Gets the items from the cache * * @param {string} id the cache id */ getCachedItems(id: string): ?FeedItemsCache { const cache = this.getCache(); const cacheKey = this.getCacheKey(id); return cache.get(cacheKey); } /** * Sets the items in the cache * * @param {string} id - the cache id * @param {Array} items - the feed items to cache */ setCachedItems(id: string, items: FeedItems) { const cache = this.getCache(); const cacheKey = this.getCacheKey(id); cache.set(cacheKey, { errors: this.errors, items, }); } /** * Gets the feed items * * @param {BoxItem} file - The file to which the task is assigned * @param {boolean} shouldRefreshCache - Optionally updates the cache * @param {Function} successCallback - the success callback which is called after data fetching is complete * @param {Function} errorCallback - the error callback which is called after data fetching is complete if there was an error * @param {Function} onError - the function to be called immediately after an error occurs * @param {Object} [options]- feature flips, etc * @param {Object} [options.shouldShowAppActivity] - feature flip the new app activity api */ feedItems( file: BoxItem, shouldRefreshCache: boolean, successCallback: Function, errorCallback: (feedItems: FeedItems, errors: ElementsXhrError[]) => void, onError: ErrorCallback, { shouldShowAnnotations = false, shouldShowAppActivity = false, shouldShowReplies = false, shouldShowTasks = true, shouldShowVersions = true, shouldUseUAA = false, }: { shouldShowAnnotations?: boolean, shouldShowAppActivity?: boolean, shouldShowReplies?: boolean, shouldShowTasks?: boolean, shouldShowVersions?: boolean, shouldUseUAA?: boolean, } = {}, ): void { const { id, permissions = {} } = file; const cachedItems = this.getCachedItems(id); if (cachedItems) { const { errors, items } = cachedItems; if (errors.length) { errorCallback(items, errors); } else { successCallback(items); } if (!shouldRefreshCache) { return; } } this.file = file; this.errors = []; this.errorCallback = onError; // Using the UAA File Activities endpoint replaces the need for these calls const annotationsPromise = !shouldUseUAA && shouldShowAnnotations ? this.fetchAnnotations(permissions, shouldShowReplies) : Promise.resolve(); const commentsPromise = () => { if (shouldUseUAA) { return Promise.resolve(); } return shouldShowReplies ? this.fetchThreadedComments(permissions) : this.fetchComments(permissions); }; const tasksPromise = !shouldUseUAA && shouldShowTasks ? this.fetchTasksNew() : Promise.resolve(); const appActivityPromise = !shouldUseUAA && shouldShowAppActivity ? this.fetchAppActivity(permissions) : Promise.resolve(); const versionsPromise = !shouldUseUAA && shouldShowVersions ? this.fetchVersions() : Promise.resolve(); const currentVersionPromise = !shouldUseUAA && shouldShowVersions ? this.fetchCurrentVersion() : Promise.resolve(); const annotationActivityType = shouldShowAnnotations && permissions[PERMISSION_CAN_VIEW_ANNOTATIONS] ? [FILE_ACTIVITY_TYPE_ANNOTATION] : []; const appActivityActivityType = shouldShowAppActivity ? [FILE_ACTIVITY_TYPE_APP_ACTIVITY] : []; const taskActivityType = shouldShowTasks ? [FILE_ACTIVITY_TYPE_TASK] : []; const versionsActivityType = shouldShowVersions ? [FILE_ACTIVITY_TYPE_VERSION] : []; const commentActivityType = permissions[PERMISSION_CAN_COMMENT] ? [FILE_ACTIVITY_TYPE_COMMENT] : []; const filteredActivityTypes = [ ...annotationActivityType, ...appActivityActivityType, ...commentActivityType, ...taskActivityType, ...versionsActivityType, ]; const fileActivitiesPromise = // Only fetch when activity types are explicitly stated shouldUseUAA && filteredActivityTypes.length ? this.fetchFileActivities(permissions, filteredActivityTypes, shouldShowReplies) : Promise.resolve(); const handleFeedItems = (feedItems: FeedItems) => { if (!this.isDestroyed()) { this.setCachedItems(id, feedItems); if (this.errors.length) { errorCallback(feedItems, this.errors); } else { successCallback(feedItems); } } }; if (shouldUseUAA) { fileActivitiesPromise.then(response => { if (!response) { return; } const parsedFeedItems = getParsedFileActivitiesResponse(response); handleFeedItems(parsedFeedItems); }); } else { Promise.all([ versionsPromise, currentVersionPromise, commentsPromise(), tasksPromise, appActivityPromise, annotationsPromise, ]).then(([versions: ?FileVersions, currentVersion: ?BoxItemVersion, ...feedItems]) => { const versionsWithCurrent = currentVersion ? this.versionsAPI.addCurrentVersion(currentVersion, versions, this.file) : undefined; const sortedFeedItems = sortFeedItems(versionsWithCurrent, ...feedItems); handleFeedItems(sortedFeedItems); }); } } fetchAnnotations(permissions: BoxItemPermission, shouldFetchReplies?: boolean): Promise<?Annotations> { this.annotationsAPI = new AnnotationsAPI(this.options); return new Promise(resolve => { this.annotationsAPI.getAnnotations( this.file.id, undefined, permissions, resolve, this.fetchFeedItemErrorCallback.bind(this, resolve), undefined, undefined, shouldFetchReplies, ); }); } /** * Fetches the comments for a file * * @param {Object} permissions - the file permissions * @return {Promise} - the file comments */ fetchComments(permissions: BoxItemPermission): Promise<?Comments> { this.commentsAPI = new CommentsAPI(this.options); return new Promise(resolve => { this.commentsAPI.getComments( this.file.id, permissions, resolve, this.fetchFeedItemErrorCallback.bind(this, resolve), ); }); } /** * Fetches a comment for a file * * @param {BoxItem} file - The file to which the comment belongs to * @param {string} commentId - comment id * @param {Function} successCallback * @param {ErrorCallback} errorCallback * @return {Promise} - the file comments */ fetchThreadedComment( file: BoxItem, commentId: string, successCallback: (comment: Comment) => void, errorCallback: ErrorCallback, ): Promise<?Comment> { const { id, permissions } = file; if (!id || !permissions) { throw getBadItemError(); } this.threadedCommentsAPI = new ThreadedCommentsAPI(this.options); return new Promise(resolve => { this.threadedCommentsAPI.getComment({ commentId, errorCallback, fileId: id, permissions, successCallback: this.fetchThreadedCommentSuccessCallback.bind(this, resolve, successCallback), }); }); } /** * Callback for successful fetch of a comment * * @param {Function} resolve - resolve function * @param {Function} successCallback - success callback * @param {Comment} comment - comment data * @return {void} */ fetchThreadedCommentSuccessCallback = (resolve: Function, successCallback: Function, comment: Comment): void => { successCallback(comment); resolve(); }; /** * Fetches the comments with replies for a file * * @param {Object} permissions - the file permissions * @return {Promise} - the file comments */ fetchThreadedComments(permissions: BoxItemPermission): Promise<?ThreadedCommentsType> { this.threadedCommentsAPI = new ThreadedCommentsAPI(this.options); return new Promise(resolve => { this.threadedCommentsAPI.getComments({ errorCallback: this.fetchFeedItemErrorCallback.bind(this, resolve), fileId: this.file.id, permissions, successCallback: resolve, }); }); } /** * Fetches the file activities for a file * * @param {BoxItemPermission} permissions - the file permissions * @param {FileActivityTypes[]} activityTypes - the activity types to filter by * @param {boolean} shouldShowReplies - specify if replies should be included in the response * @return {Promise} - the file comments */ fetchFileActivities( permissions: BoxItemPermission, activityTypes: FileActivityTypes[], shouldShowReplies?: boolean = false, ): Promise<Object> { this.fileActivitiesAPI = new FileActivitiesAPI(this.options); return new Promise(resolve => { this.fileActivitiesAPI.getActivities({ errorCallback: this.fetchFeedItemErrorCallback.bind(this, resolve), fileID: this.file.id, permissions, successCallback: resolve, activityTypes, shouldShowReplies, }); }); } /** * Fetches replies (comments) of a comment or annotation * * @param {BoxItem} file - The file to which the comment or annotation belongs to * @param {string} commentFeedItemId - ID of the comment or annotation * @param {CommentFeedItemType} commentFeedItemType - Type of the comment or annotation * @param {Function} successCallback * @param {ErrorCallback} errorCallback * @return {void} */ fetchReplies( file: BoxItem, commentFeedItemId: string, commentFeedItemType: CommentFeedItemType, successCallback: (comments: Array<Comment>) => void, errorCallback: ErrorCallback, ): void { const { id, permissions } = file; if (!id || !permissions) { throw getBadItemError(); } this.file = file; this.errorCallback = errorCallback; this.updateFeedItem({ isRepliesLoading: true }, commentFeedItemId); const successCallbackFn = ({ entries }: ThreadedCommentsType) => { this.updateFeedItem( { isRepliesLoading: false, replies: entries, total_reply_count: entries.length }, commentFeedItemId, ); successCallback(entries); }; const errorCallbackFn = (error: ErrorResponseData, code: string) => { this.fetchRepliesErrorCallback(error, code, commentFeedItemId); }; if (commentFeedItemType === FEED_ITEM_TYPE_ANNOTATION) { this.annotationsAPI = new AnnotationsAPI(this.options); this.annotationsAPI.getAnnotationReplies( file.id, commentFeedItemId, permissions, successCallbackFn, errorCallbackFn, ); } else if (commentFeedItemType === FEED_ITEM_TYPE_COMMENT) { this.threadedCommentsAPI = new ThreadedCommentsAPI(this.options); this.threadedCommentsAPI.getCommentReplies({ fileId: file.id, commentId: commentFeedItemId, permissions, successCallback: successCallbackFn, errorCallback: errorCallbackFn, }); } } /** * Fetches the versions for a file * * @return {Promise} - the file versions */ fetchVersions(): Promise<?FileVersions> { this.versionsAPI = new VersionsAPI(this.options); return new Promise(resolve => { this.versionsAPI.getVersions( this.file.id, resolve, this.fetchFeedItemErrorCallback.bind(this, resolve), undefined, undefined, FEED_FILE_VERSIONS_FIELDS_TO_FETCH, ); }); } /** * Fetches the current version for a file * * @return {Promise} - the file versions */ fetchCurrentVersion(): Promise<?BoxItemVersion> { this.versionsAPI = new VersionsAPI(this.options); return new Promise(resolve => { const { file_version = {} } = this.file; this.versionsAPI.getVersion( this.file.id, file_version.id, resolve, this.fetchFeedItemErrorCallback.bind(this, resolve), ); }); } /** * Fetches the tasks for a file * * @return {Promise} - the feed items */ fetchTasksNew(): Promise<?Tasks> { this.tasksNewAPI = new TasksNewAPI(this.options); return new Promise(resolve => { this.tasksNewAPI.getTasksForFile({ file: { id: this.file.id }, successCallback: resolve, errorCallback: (err, code) => this.fetchFeedItemErrorCallback(resolve, err, code), }); }); } /** * Error callback for fetching feed items. * Should only call the error callback if the response is a 401, 429 or >= 500 * * @param {Function} resolve - the function which will be called on error * @param {Object} e - the axios error * @param {string} code - the error code * @return {void} */ fetchFeedItemErrorCallback(resolve: Function, e: ElementsXhrError, code: string) { const { status } = e; const shouldDisplayError = isUserCorrectableError(status); this.feedErrorCallback(shouldDisplayError, e, code); resolve(); } /** * Updates a task assignment * * @param {BoxItem} file - The file to which the task is assigned * @param {string} taskId - ID of task to be updated * @param {string} taskCollaboratorId - Task assignment ID * @param {TaskCollabStatus} taskCollaboratorStatus - New task assignment status * @param {Function} successCallback - the function which will be called on success * @param {Function} errorCallback - the function which will be called on error * @return {void} */ updateTaskCollaborator = ( file: BoxItem, taskId: string, taskCollaboratorId: string, taskCollaboratorStatus: TaskCollabStatus, successCallback: Function, errorCallback: ErrorCallback, ): void => { if (!file.id) { throw getBadItemError(); } this.file = file; this.errorCallback = errorCallback; this.updateFeedItem({ isPending: true }, taskId); const collaboratorsApi = new TaskCollaboratorsAPI(this.options); this.taskCollaboratorsAPI.push(collaboratorsApi); const taskCollaboratorPayload = { id: taskCollaboratorId, status: taskCollaboratorStatus, }; const handleError = (e: ElementsXhrError, code: string) => { let errorMessage; switch (taskCollaboratorStatus) { case TASK_NEW_APPROVED: errorMessage = messages.taskApproveErrorMessage; break; case TASK_NEW_COMPLETED: errorMessage = messages.taskCompleteErrorMessage; break; case TASK_NEW_REJECTED: errorMessage = messages.taskRejectErrorMessage; break; default: errorMessage = messages.taskCompleteErrorMessage; } this.updateFeedItem(this.createFeedError(errorMessage, messages.taskActionErrorTitle), taskId); this.feedErrorCallback(true, e, code); }; collaboratorsApi.updateTaskCollaborator({ file, taskCollaborator: taskCollaboratorPayload, successCallback: (taskCollab: TaskCollabAssignee) => { this.updateTaskCollaboratorSuccessCallback(taskId, file, taskCollab, successCallback, handleError); }, errorCallback: handleError, }); }; /** * Updates the task assignment state of the updated task * * @param {string} taskId - Box task id * @param {TaskAssignment} updatedCollaborator - New task assignment from API * @param {Function} successCallback - the function which will be called on success * @return {void} */ updateTaskCollaboratorSuccessCallback = ( taskId: string, file: { id: string }, updatedCollaborator: TaskCollabAssignee, successCallback: Function, errorCallback: Function, ): void => { this.tasksNewAPI = new TasksNewAPI(this.options); this.tasksNewAPI.getTask({ id: taskId, file, successCallback: task => { this.updateFeedItem({ ...task, isPending: false }, taskId); successCallback(updatedCollaborator); }, errorCallback, }); }; /** * Updates a task in the new API * * @param {BoxItem} file - The file to which the task is assigned * @param {string} task - The update task payload object * @param {Function} successCallback - the function which will be called on success * @param {Function} errorCallback - the function which will be called on error * @return {void} */ updateTaskNew = async ( file: BoxItem, task: TaskUpdatePayload, successCallback: () => void = noop, errorCallback: ErrorCallback = noop, ) => { if (!file.id) { throw getBadItemError(); } this.file = file; this.errorCallback = errorCallback; this.tasksNewAPI = new TasksNewAPI(this.options); this.updateFeedItem({ isPending: true }, task.id); try { // create request for the size of each group by ID // TODO: use async/await for both creating and editing tasks const groupInfoPromises: Array<Promise<any>> = task.addedAssignees .filter( (assignee: SelectorItem<UserMini | GroupMini>) => assignee.item && assignee.item.type === 'group', ) .map(assignee => assignee.id) .map(groupId => { return new GroupsAPI(this.options).getGroupCount({ file, group: { id: groupId }, }); }); const groupCounts: Array<{ total_count: number }> = await Promise.all(groupInfoPromises); const hasAnyGroupCountExceeded: boolean = groupCounts.some( groupInfo => groupInfo.total_count > TASK_MAX_GROUP_ASSIGNEES, ); const warning = { code: ERROR_CODE_GROUP_EXCEEDS_LIMIT, type: 'warning', }; if (hasAnyGroupCountExceeded) { this.feedErrorCallback(false, warning, ERROR_CODE_GROUP_EXCEEDS_LIMIT); return; } await new Promise((resolve, reject) => { this.tasksNewAPI.updateTaskWithDeps({ file, task, successCallback: resolve, errorCallback: reject, }); }); await new Promise((resolve, reject) => { this.tasksNewAPI.getTask({ file, id: task.id, successCallback: (taskData: Task) => { this.updateFeedItem( { ...taskData, isPending: false, }, task.id, ); resolve(); }, errorCallback: (e: ElementsXhrError) => { this.updateFeedItem({ isPending: false }, task.id); this.feedErrorCallback(false, e, ERROR_CODE_UPDATE_TASK); reject(); }, }); }); // everything succeeded, so call the passed in success callback if (!this.isDestroyed()) { successCallback(); } } catch (e) { this.updateFeedItem({ isPending: false }, task.id); this.feedErrorCallback(false, e, ERROR_CODE_UPDATE_TASK); } }; /** * Deletes a comment. * * @param {BoxItem} file - The file to which the comment belongs to * @param {string} commentId - Comment ID * @param {BoxCommentPermission} permissions - Permissions for the comment * @param {Function} successCallback - the function which will be called on success * @param {Function} errorCallback - the function which will be called on error * @return {void} */ deleteComment = ( file: BoxItem, commentId: string, permissions: BoxCommentPermission, successCallback: Function, errorCallback: ErrorCallback, ): void => { this.commentsAPI = new CommentsAPI(this.options); if (!file.id) { throw getBadItemError(); } this.file = file; this.errorCallback = errorCallback; this.updateFeedItem({ isPending: true }, commentId); this.commentsAPI.deleteComment({ file, commentId, permissions, successCallback: this.deleteFeedItem.bind(this, commentId, successCallback), errorCallback: (e: ElementsXhrError, code: string) => { this.deleteCommentErrorCallback(e, code, commentId); }, }); }; /** * Deletes a threaded comment (using ThreadedComments API). * * @param {BoxItem} file - The file to which the comment belongs to * @param {string} commentId - Comment ID * @param {BoxCommentPermission} permissions - Permissions for the comment * @param {Function} successCallback - the function which will be called on success * @param {Function} errorCallback - the function which will be called on error * @return {void} */ deleteThreadedComment = ( file: BoxItem, commentId: string, permissions: BoxCommentPermission, successCallback: Function, errorCallback: ErrorCallback, ): void => { if (!file.id) { throw getBadItemError(); } this.file = file; this.errorCallback = errorCallback; this.updateFeedItem({ isPending: true }, commentId); this.threadedCommentsAPI = new ThreadedCommentsAPI(this.options); this.threadedCommentsAPI.deleteComment({ fileId: file.id, commentId, permissions, successCallback: this.deleteFeedItem.bind(this, commentId, successCallback), errorCallback: (e: ElementsXhrError, code: string) => { this.deleteCommentErrorCallback(e, code, commentId); }, }); }; /** * Deletes a reply (using ThreadedComments API). * * @param {BoxItem} file - The file to which the comment belongs to * @param {string} id - id of the reply (comment) * @param {string} parentId - id of the parent feed item * @param {BoxCommentPermission} permissions - Permissions for the comment * @param {Function} successCallback - the function which will be called on success * @param {Function} errorCallback - the function which will be called on error * @return {void} */ deleteReply = ( file: BoxItem, id: string, parentId: string, permissions: BoxCommentPermission, successCallback: () => void, errorCallback: ErrorCallback, ): void => { if (!file.id) { throw getBadItemError(); } this.file = file; this.errorCallback = errorCallback; this.updateReplyItem({ isPending: true }, parentId, id); this.threadedCommentsAPI = new ThreadedCommentsAPI(this.options); this.threadedCommentsAPI.deleteComment({ fileId: file.id, commentId: id, permissions, successCallback: this.deleteReplySuccessCallback.bind(this, id, parentId, successCallback), errorCallback: (e: ElementsXhrError, code: string) => { this.deleteReplyErrorCallback(e, code, id, parentId); }, }); }; /** * Callback for successful deletion of a reply. * * @param {string} id - ID of the reply * @param {string} parentId - ID of the parent feed item * @param {Function} successCallback - success callback * @return {void} */ deleteReplySuccessCallback = (id: string, parentId: string, successCallback: Function): void => { this.modifyFeedItemRepliesCountBy(parentId, -1); this.deleteReplyItem(id, parentId, successCallback); }; /** * Error callback for deleting a comment * * @param {ElementsXhrError} e - the error returned by the API * @param {string} code - the error code * @param {string} commentId - the comment id * @return {void} */ deleteCommentErrorCallback = (e: ElementsXhrError, code: string, commentId: string) => { this.updateFeedItem(this.createFeedError(messages.commentDeleteErrorMessage), commentId); this.feedErrorCallback(true, e, code); }; /** * Error callback for deleting a reply * * @param {ElementsXhrError} error - the error returned by the API * @param {string} code - the error code * @param {string} id - the reply (comment) id * @param {string} parentId - the comment id of the parent feed item * @return {void} */ deleteReplyErrorCallback = (error: ElementsXhrError, code: string, id: string, parentId: string) => { this.updateReplyItem(this.createFeedError(messages.commentDeleteErrorMessage), parentId, id); this.feedErrorCallback(true, error, code); }; /** * Creates a task. * * @param {BoxItem} file - The file to which the task is assigned * @param {Object} currentUser - the user who performed the action * @param {string} message - Task text * @param {Array} assignees - List of assignees * @param {number} dueAt - Task's due date * @param {Function} successCallback - the function which will be called on success * @param {Function} errorCallback - the function which will be called on error * @return {void} */ createTaskNew = ( file: BoxItem, currentUser: User, message: string, assignees: SelectorItems<>, taskType: TaskType, dueAt: ?string, completionRule: TaskCompletionRule, successCallback: Function, errorCallback: ErrorCallback, ): void => { if (!file.id) { throw getBadItemError(); } this.file = file; this.errorCallback = errorCallback; const uuid = uniqueId('task_'); let dueAtString; if (dueAt) { const dueAtDate: Date = new Date(dueAt); dueAtString = dueAtDate.toISOString(); } // TODO: make pending task generator a function const pendingTask: TaskNew = { created_by: { type: 'task_collaborator', target: currentUser, id: uniqueId(), role: 'CREATOR', status: TASK_NEW_INITIAL_STATUS, }, completion_rule: completionRule, created_at: new Date().toISOString(), due_at: dueAtString, id: uuid, description: message, type: FEED_ITEM_TYPE_TASK, assigned_to: { entries: assignees.map((assignee: SelectorItem<UserMini | GroupMini>) => ({ id: uniqueId(), target: { ...assignee, avatar_url: '', type: 'user' }, status: TASK_NEW_INITIAL_STATUS, permissions: { can_delete: false, can_update: false, }, role: 'ASSIGNEE', type: 'task_collaborator', })), limit: 10, next_marker: null, }, permissions: { can_update: false, can_delete: false, can_create_task_collaborator: false, can_create_task_link: false, }, task_links: { entries: [ { id: uniqueId(), type: 'task_link', target: { type: 'file', ...file, }, permissions: { can_delete: false, can_update: false, }, }, ], limit: 1, next_marker: null, }, task_type: taskType, status: TASK_NEW_NOT_STARTED, }; const taskPayload: TaskPayload = { description: message, due_at: dueAtString, task_type: taskType, completion_rule: completionRule, }; // create request for the size of each group by ID const groupInfoPromises: Array<Promise<any>> = assignees .filter((assignee: SelectorItem<UserMini | GroupMini>) => (assignee.item && assignee.item.type) === 'group') .map(assignee => assignee.id) .map(groupId => { return new GroupsAPI(this.options).getGroupCount({ file, group: { id: groupId }, }); }); // Fetch each group size in parallel --> return an array of group sizes Promise.all(groupInfoPromises) .then((groupCounts: Array<{ total_count: number }>) => { const hasAnyGroupCountExceeded: boolean = groupCounts.some( groupInfo => groupInfo.total_count > TASK_MAX_GROUP_ASSIGNEES, ); const warning = { code: ERROR_CODE_GROUP_EXCEEDS_LIMIT, type: 'warning', }; if (hasAnyGroupCountExceeded) { this.feedErrorCallback(false, warning, ERROR_CODE_GROUP_EXCEEDS_LIMIT); return; } this.tasksNewAPI = new TasksNewAPI(this.options); this.tasksNewAPI.createTaskWithDeps({ file, task: taskPayload, assignees, successCallback: (taskWithDepsData: any) => { this.addPendingItem(this.file.id, currentUser, pendingTask); this.updateFeedItem( { ...taskWithDepsData, task_links: { entries: taskWithDepsData.task_links, next_marker: null, limit: 1, }, assigned_to: { entries: taskWithDepsData.assigned_to, next_marker: null, limit: taskWithDepsData.assigned_to.length, }, isPending: false,