UNPKG

box-ui-elements

Version:
1,360 lines (1,214 loc) • 45.9 kB
/** * @flow * @file Activity feed sidebar component * @author Box */ import * as React from 'react'; import classNames from 'classnames'; import debounce from 'lodash/debounce'; import flow from 'lodash/flow'; import getProp from 'lodash/get'; import noop from 'lodash/noop'; import uniqueId from 'lodash/uniqueId'; import { FormattedMessage } from 'react-intl'; import { generatePath, type ContextRouter } from 'react-router-dom'; import ActivityFeed from './activity-feed'; import AddTaskButton from './AddTaskButton'; import API from '../../api'; import messages from '../common/messages'; import SidebarContent from './SidebarContent'; import { EVENT_DATA_READY, EVENT_JS_READY } from '../common/logger/constants'; import { getBadUserError } from '../../utils/error'; import { mark } from '../../utils/performance'; import { withAnnotatorContext } from '../common/annotator-context'; import { withAPIContext } from '../common/api-context'; import { withErrorBoundary } from '../common/error-boundary'; import { withFeatureConsumer, isFeatureEnabled } from '../common/feature-checking'; import { withLogger } from '../common/logger'; import { withRouterAndRef } from '../common/routing'; import ActivitySidebarFilter from './ActivitySidebarFilter'; import { ViewType, FeedEntryType } from '../common/types/SidebarNavigation'; import { ACTIVITY_FILTER_OPTION_ALL, ACTIVITY_FILTER_OPTION_RESOLVED, ACTIVITY_FILTER_OPTION_TASKS, ACTIVITY_FILTER_OPTION_UNRESOLVED, DEFAULT_COLLAB_DEBOUNCE, ERROR_CODE_FETCH_ACTIVITY, FEED_ITEM_TYPE_ANNOTATION, FEED_ITEM_TYPE_COMMENT, FEED_ITEM_TYPE_TASK, FEED_ITEM_TYPE_VERSION, ORIGIN_ACTIVITY_SIDEBAR, SIDEBAR_VIEW_ACTIVITY, TASK_COMPLETION_RULE_ALL, METRIC_TYPE_UAA_PARITY_METRIC, } from '../../constants'; import type { TaskCompletionRule, TaskType, TaskNew, TaskUpdatePayload, TaskCollabStatus, } from '../../common/types/tasks'; import type { Annotation, AnnotationPermission, ActivityFilterItemType, ActivityFilterOption, BoxCommentPermission, Comment, CommentFeedItemType, FocusableFeedItem, FocusableFeedItemType, FeedItem, FeedItems, FeedItemStatus, FeedItemType, } from '../../common/types/feed'; import type { ErrorContextProps, ElementsXhrError } from '../../common/types/api'; import type { WithLoggerProps } from '../../common/types/logging'; import type { SelectorItems, User, UserMini, GroupMini, BoxItem } from '../../common/types/core'; import type { Errors, GetProfileUrlCallback } from '../common/flowTypes'; import type { Translations } from './flowTypes'; import type { FeatureConfig } from '../common/feature-checking'; import type { WithAnnotatorContextProps } from '../common/annotator-context'; import './ActivitySidebar.scss'; import type { OnAnnotationEdit, OnAnnotationStatusChange } from './activity-feed/comment/types'; import type { InternalSidebarNavigation, InternalSidebarNavigationHandler } from '../common/types/SidebarNavigation'; type ExternalProps = { activeFeedEntryId?: string, activeFeedEntryType?: FocusableFeedItemType, currentUser?: User, currentUserError?: Errors, getUserProfileUrl?: GetProfileUrlCallback, hasReplies?: boolean, hasTasks?: boolean, hasVersions?: boolean, internalSidebarNavigation?: InternalSidebarNavigation, internalSidebarNavigationHandler?: InternalSidebarNavigationHandler, onCommentCreate: Function, onCommentDelete: (comment: Comment) => any, onCommentUpdate: () => any, onTaskAssignmentUpdate: Function, onTaskCreate: Function, onTaskDelete: (id: string) => any, onTaskUpdate: () => any, onTaskView: (id: string, isCreator: boolean) => any, routerDisabled?: boolean, } & ErrorContextProps & WithAnnotatorContextProps; type PropsWithoutContext = { elementId: string, file: BoxItem, hasSidebarInitialized?: boolean, isDisabled: boolean, onAnnotationSelect: Function, onFilterChange: (status?: ActivityFilterItemType) => void, onVersionChange: Function, onVersionHistoryClick?: Function, translations?: Translations, } & ExternalProps & WithLoggerProps & ContextRouter; type Props = { api: API, features: FeatureConfig, } & PropsWithoutContext; type State = { activityFeedError?: Errors, approverSelectorContacts: SelectorItems<UserMini | GroupMini>, contactsLoaded?: boolean, feedItems?: FeedItems, feedItemsStatusFilter?: ActivityFilterItemType, mentionSelectorContacts?: SelectorItems<UserMini>, }; export const activityFeedInlineError: Errors = { inlineError: { title: messages.errorOccured, content: messages.activityFeedItemApiError, }, }; const MARK_NAME_DATA_LOADING = `${ORIGIN_ACTIVITY_SIDEBAR}_data_loading`; const MARK_NAME_DATA_READY = `${ORIGIN_ACTIVITY_SIDEBAR}_${EVENT_DATA_READY}`; const MARK_NAME_JS_READY = `${ORIGIN_ACTIVITY_SIDEBAR}_${EVENT_JS_READY}`; mark(MARK_NAME_JS_READY); class ActivitySidebar extends React.PureComponent<Props, State> { static defaultProps = { annotatorState: {}, emitActiveAnnotationChangeEvent: noop, emitAnnotationRemoveEvent: noop, emitAnnotationReplyCreateEvent: noop, emitAnnotationReplyDeleteEvent: noop, emitAnnotationReplyUpdateEvent: noop, emitAnnotationUpdateEvent: noop, getAnnotationsMatchPath: noop, getAnnotationsPath: noop, hasReplies: false, hasTasks: true, hasVersions: true, isDisabled: false, onAnnotationSelect: noop, onCommentCreate: noop, onCommentDelete: noop, onCommentUpdate: noop, onFilterChange: noop, onTaskAssignmentUpdate: noop, onTaskCreate: noop, onTaskDelete: noop, onTaskUpdate: noop, onVersionChange: noop, onVersionHistoryClick: noop, }; constructor(props: Props) { super(props); // eslint-disable-next-line react/prop-types const { logger } = this.props; mark(MARK_NAME_DATA_LOADING); logger.onReadyMetric({ endMarkName: MARK_NAME_JS_READY, }); this.state = {}; } componentDidMount() { this.fetchFeedItems(true); } handleAnnotationDelete = ({ id, permissions }: { id: string, permissions: AnnotationPermission }) => { const { api, emitAnnotationRemoveEvent, file } = this.props; emitAnnotationRemoveEvent(id, true); api.getFeedAPI(false).deleteAnnotation( file, id, permissions, this.deleteAnnotationSuccess.bind(this, id), this.feedErrorCallback, ); this.fetchFeedItems(); }; handleAnnotationEdit: OnAnnotationEdit = ({ id, text, permissions }) => { const { api, emitAnnotationUpdateEvent, file } = this.props; emitAnnotationUpdateEvent( { id, description: { message: text, }, }, true, ); api.getFeedAPI(false).updateAnnotation( file, id, text, undefined, permissions, (annotation: Annotation) => { emitAnnotationUpdateEvent(annotation); this.feedSuccessCallback(); }, this.feedErrorCallback, ); this.fetchFeedItems(); }; handleAnnotationStatusChange: OnAnnotationStatusChange = ({ id, permissions, status }) => { const { api, emitAnnotationUpdateEvent, file } = this.props; emitAnnotationUpdateEvent({ id, status }, true); api.getFeedAPI(false).updateAnnotation( file, id, undefined, status, permissions, (annotation: Annotation) => { emitAnnotationUpdateEvent(annotation); this.feedSuccessCallback(); }, this.feedErrorCallback, ); this.fetchFeedItems(); }; deleteAnnotationSuccess(id: string) { const { emitAnnotationRemoveEvent } = this.props; this.feedSuccessCallback(); emitAnnotationRemoveEvent(id); } /** * Success callback for fetching feed items */ feedSuccessCallback = (): void => { this.fetchFeedItems(); }; /** * Error callback for fetching feed items * * @param {Error} e - the error which occured * @param {Error} code - the code for the error * @param {Object} contextInfo - the context info for the error */ feedErrorCallback = (e: ElementsXhrError, code: string, contextInfo?: Object) => { this.errorCallback(e, code, contextInfo); this.fetchFeedItems(); }; createTask = ( message: string, assignees: SelectorItems<>, taskType: TaskType, dueAt: ?string, completionRule: TaskCompletionRule, onSuccess: ?Function, onError: ?Function, ): void => { const { api, currentUser, file } = this.props; if (!currentUser) { throw getBadUserError(); } const errorCallback = (e, code, contextInfo) => { if (onError) { onError(e, code, contextInfo); } this.feedErrorCallback(e, code, contextInfo); }; const successCallback = () => { if (onSuccess) { onSuccess(); } this.feedSuccessCallback(); }; api.getFeedAPI(false).createTaskNew( file, currentUser, message, assignees, taskType, dueAt, completionRule, successCallback, errorCallback, ); // need to load the pending item this.fetchFeedItems(); }; deleteTask = (task: TaskNew): void => { const { file, api, onTaskDelete } = this.props; api.getFeedAPI(false).deleteTaskNew( file, task, (taskId: string) => { this.feedSuccessCallback(); onTaskDelete(taskId); }, this.feedErrorCallback, ); // need to load the pending item this.fetchFeedItems(); }; updateTask = (task: TaskUpdatePayload, onSuccess: ?Function, onError: ?Function): void => { const { api, file, onTaskUpdate } = this.props; const errorCallback = (e, code) => { if (onError) { onError(e, code); } this.feedErrorCallback(e, code); }; const successCallback = () => { this.feedSuccessCallback(); if (onSuccess) { onSuccess(); } onTaskUpdate(); }; api.getFeedAPI(false).updateTaskNew(file, task, successCallback, errorCallback); // need to load the pending item this.fetchFeedItems(); }; updateTaskAssignment = (taskId: string, taskAssignmentId: string, status: TaskCollabStatus): void => { const { api, currentUser = {}, file, onTaskAssignmentUpdate } = this.props; const successCallback = () => { this.feedSuccessCallback(); onTaskAssignmentUpdate(taskId, taskAssignmentId, status, currentUser.id); }; api.getFeedAPI(false).updateTaskCollaborator( file, taskId, taskAssignmentId, status, successCallback, this.feedErrorCallback, ); // need to load the pending item this.fetchFeedItems(); }; /** * Deletes a comment via the API. * * @param {Object} args - A subset of the comment * @return void */ deleteComment = ({ id, permissions }: { id: string, permissions: BoxCommentPermission }): void => { const { api, file, hasReplies, onCommentDelete } = this.props; const successCallback = (comment: Comment) => { this.feedSuccessCallback(); onCommentDelete(comment); }; if (hasReplies) { api.getFeedAPI(false).deleteThreadedComment(file, id, permissions, successCallback, this.feedErrorCallback); } else { api.getFeedAPI(false).deleteComment(file, id, permissions, successCallback, this.feedErrorCallback); } // need to load the pending item this.fetchFeedItems(); }; /** * Deletes a reply via the API. * * @param {Object} args - A subset of the comment * @return void */ deleteReply = ({ id, parentId, permissions, }: { id: string, parentId: string, permissions: BoxCommentPermission, }): void => { const { api, emitAnnotationReplyDeleteEvent, file } = this.props; emitAnnotationReplyDeleteEvent(id, parentId, true); api.getFeedAPI(false).deleteReply( file, id, parentId, permissions, this.deleteReplySuccessCallback.bind(this, id, parentId), this.feedErrorCallback, ); // need to load the pending item this.fetchFeedItems(); }; /** * Handles a successful deletion of a reply * * @private * @param {string} id - The id of the reply * @param {string} parentId - The id of the reply's parent item * @return {void} */ deleteReplySuccessCallback = (id: string, parentId: string) => { const { emitAnnotationReplyDeleteEvent } = this.props; this.feedSuccessCallback(); emitAnnotationReplyDeleteEvent(id, parentId); }; updateComment = ( id: string, text?: string, status?: FeedItemStatus, hasMention: boolean, permissions: BoxCommentPermission, onSuccess: ?Function, onError: ?Function, ): void => { const { api, file, hasReplies, onCommentUpdate } = this.props; const errorCallback = (e, code) => { if (onError) { onError(e, code); } this.feedErrorCallback(e, code); }; const successCallback = () => { this.feedSuccessCallback(); if (onSuccess) { onSuccess(); } onCommentUpdate(); }; if (hasReplies) { api.getFeedAPI(false).updateThreadedComment( file, id, text, status, permissions, successCallback, errorCallback, ); } else { api.getFeedAPI(false).updateComment( file, id, text || '', hasMention, permissions, successCallback, errorCallback, ); } // need to load the pending item this.fetchFeedItems(); }; /** * Updates a reply * * @param {string} id - id of the reply * @param {string} parentId - id of the parent item * @param {string} text - the reply updated text * @param {BoxCommentPermission} permissions - permissions associated with the reply * @param {Function} onSuccess - the success callback * @param {Function} onError - the error callback * @return {void} */ updateReply = ( id: string, parentId: string, text: string, permissions: BoxCommentPermission, onSuccess: ?Function, onError: ?Function, ): void => { const { api, emitAnnotationReplyUpdateEvent, file } = this.props; emitAnnotationReplyUpdateEvent({ id, tagged_message: text }, parentId, true); api.getFeedAPI(false).updateReply( file, id, parentId, text, permissions, this.updateReplySuccessCallback.bind(this, parentId, onSuccess), (error, code) => { if (onError) { onError(error, code); } this.feedErrorCallback(error, code); }, ); // need to load the pending item this.fetchFeedItems(); }; /** * Updates replies of a comment or annotation in the Feed * * @param {string} id - id of the feed item * @param {Array<Comment>} replies - replies * @return {void} */ updateReplies = (id: string, replies: Array<Comment>) => { const { activeFeedEntryId, api, file, history, internalSidebarNavigationHandler, routerDisabled } = this.props; const { feedItems } = this.state; if (!feedItems) { return; } const feedAPI = api.getFeedAPI(false); feedAPI.file = file; // Detect if replies are being hidden and activeFeedEntryId belongs to a reply // that is in currently being updated parent, in order to disable active item if ( activeFeedEntryId && replies.length === 1 && feedItems.some( (item: FeedItem) => item.id === id && item === this.getCommentFeedItemByReplyId(feedItems, activeFeedEntryId), ) ) { if (routerDisabled && internalSidebarNavigationHandler) { internalSidebarNavigationHandler( { sidebar: ViewType.ACTIVITY, }, true, ); } else { history.replace(this.getActiveCommentPath()); } } feedAPI.updateFeedItem({ replies }, id); this.fetchFeedItems(); }; /** * Handles a successful update of a reply * * @private * @param {string} parentId - The id of the reply's parent item * @param {Function} onSuccess - the success callback * @param {Comment} reply - The reply comment object * @return {void} */ updateReplySuccessCallback = (parentId: string, onSuccess: ?Function, reply: Comment) => { const { emitAnnotationReplyUpdateEvent } = this.props; this.feedSuccessCallback(); emitAnnotationReplyUpdateEvent(reply, parentId); if (onSuccess) { onSuccess(); } }; /** * Posts a new comment to the API * * @param {string} text - The comment's text * @param {boolean} hasMention - The comment's text * @return {void} */ createComment = (text: string, hasMention: boolean): void => { const { api, currentUser, file, hasReplies, onCommentCreate } = this.props; if (!currentUser) { throw getBadUserError(); } const successCallback = (comment: Comment) => { onCommentCreate(comment); this.feedSuccessCallback(); }; if (hasReplies) { api.getFeedAPI(false).createThreadedComment( file, currentUser, text, successCallback, this.feedErrorCallback, ); } else { api.getFeedAPI(false).createComment( file, currentUser, text, hasMention, successCallback, this.feedErrorCallback, ); } // need to load the pending item this.fetchFeedItems(); }; /** * Posts a new reply to the API * * @param {string} parentId - The id of the parent item * @param {CommentFeedItemType} parentType - The type of the parent item * @param {string} text - The text of reply * @return {void} */ createReply = (parentId: string, parentType: CommentFeedItemType, text: string): void => { const { api, currentUser, emitAnnotationReplyCreateEvent, file } = this.props; if (!currentUser) { throw getBadUserError(); } const eventRequestId = uniqueId('comment_'); emitAnnotationReplyCreateEvent({ tagged_message: text }, eventRequestId, parentId, true); api.getFeedAPI(false).createReply( file, currentUser, parentId, parentType, text, this.createReplySuccessCallback.bind(this, eventRequestId, parentId), this.feedErrorCallback, ); // need to load the pending item this.fetchFeedItems(); }; /** * Handles a successful creation of a reply * * @private * @param {string} eventRequestId - The id of the parent item * @param {string} parentId - The id of the reply's parent item * @param {Comment} reply - The reply comment object * @return {void} */ createReplySuccessCallback = (eventRequestId: string, parentId: string, reply: Comment) => { const { emitAnnotationReplyCreateEvent } = this.props; this.feedSuccessCallback(); emitAnnotationReplyCreateEvent(reply, eventRequestId, parentId); }; /** * Deletes an app activity item via the API. * * @param {Object} args - A subset of the app activity * @return void */ deleteAppActivity = ({ id }: { id: string }): void => { const { api, file } = this.props; api.getFeedAPI(false).deleteAppActivity(file, id, this.feedSuccessCallback, this.feedErrorCallback); // need to load the pending item this.fetchFeedItems(); }; /** * Fetches the feed items for the sidebar * * @param {boolean} shouldRefreshCache true if the cache should be refreshed * @param {boolean} shouldDestroy true if the api factory should be destroyed */ fetchFeedItems(shouldRefreshCache: boolean = false, shouldDestroy: boolean = false) { const { activeFeedEntryId, activeFeedEntryType, api, file, features, hasReplies: shouldShowReplies, hasTasks: shouldShowTasks, hasVersions: shouldShowVersions, } = this.props; const shouldFetchReplies = shouldRefreshCache && shouldShowReplies && activeFeedEntryId && activeFeedEntryType === FEED_ITEM_TYPE_COMMENT; const shouldShowAppActivity = isFeatureEnabled(features, 'activityFeed.appActivity.enabled'); const shouldShowAnnotations = isFeatureEnabled(features, 'activityFeed.annotations.enabled'); const shouldUseUAA = isFeatureEnabled(features, 'activityFeed.uaaIntegration.enabled'); api.getFeedAPI(shouldDestroy).feedItems( file, shouldRefreshCache, shouldFetchReplies ? this.fetchRepliesForFeedItems : this.fetchFeedItemsSuccessCallback, this.fetchFeedItemsErrorCallback, this.errorCallback, { shouldShowAnnotations, shouldShowAppActivity, shouldShowReplies, shouldShowTasks, shouldShowVersions, shouldUseUAA, }, shouldUseUAA ? this.logAPIParity : undefined, ); } fetchRepliesForFeedItems = (feedItems: FeedItems) => { const { activeFeedEntryId } = this.props; if (!activeFeedEntryId) { return; } this.getActiveFeedEntryData(feedItems) .then(({ id, type }) => { if ( !id || !type || this.isActiveEntryInFeed(feedItems, activeFeedEntryId) || !this.isItemTypeComment(type) ) { return Promise.resolve(feedItems); } const parentType: CommentFeedItemType = type === FEED_ITEM_TYPE_COMMENT ? FEED_ITEM_TYPE_COMMENT : FEED_ITEM_TYPE_ANNOTATION; return this.getFeedItemsWithReplies(feedItems, id, parentType); }) .then(updatedItems => this.fetchFeedItemsSuccessCallback(updatedItems)) .catch(error => this.fetchFeedItemsErrorCallback(feedItems, [error])); }; /** * Handles a successful feed API fetch * * @private * @param {Array} feedItems - the feed items * @return {void} */ fetchFeedItemsSuccessCallback = (feedItems: FeedItems): void => { const { file: { id: fileId }, logger, } = this.props; mark(MARK_NAME_DATA_READY); // Only emit metric if has >1 activity feed items (there should always at least be the current version) if (feedItems.length > 1) { logger.onDataReadyMetric( { endMarkName: MARK_NAME_DATA_READY, startMarkName: MARK_NAME_DATA_LOADING, }, fileId, ); } this.setState({ feedItems, activityFeedError: undefined }); }; /** * Logs diff between UAA and v2 API data * * @param {{}[]} responseParity array of aggragated responses from UAA and v2 * @param {{}} parsedDataParity parsed data from UAA and v2 * @return {void} */ logAPIParity = (parityData: { uaaFeedItems: FeedItems, v2FeedItems: FeedItems }): void => { const { logger } = this.props; logger.onPreviewMetric({ parityData, type: METRIC_TYPE_UAA_PARITY_METRIC, }); }; /** * Handles a failed feed item fetch * * @private * @param {Error} e - API error * @return {void} */ fetchFeedItemsErrorCallback = (feedItems: FeedItems, errors: ElementsXhrError[]): void => { const { onError } = this.props; this.setState({ feedItems, activityFeedError: activityFeedInlineError, }); if (Array.isArray(errors) && errors.length) { onError(new Error('Fetch feed items error'), ERROR_CODE_FETCH_ACTIVITY, { showNotification: true, errors: errors.map(({ code }) => code), }); } }; getCommentFeedItemWithReplies = <T: { replies?: Array<Comment> }>(feedItem: T, replies: Array<Comment>): T => ({ ...feedItem, replies, }); getFeedItemsWithReplies = (feedItems: FeedItems, id?: string, type?: CommentFeedItemType): Promise<FeedItems> => { const { api, file } = this.props; return new Promise((resolve, reject) => { if (!id || !type) { resolve(feedItems); return; } api.getFeedAPI(false).fetchReplies( file, id, type, replies => { const updatedFeedItems = feedItems.map(item => { if (item.id === id && this.isItemTypeComment(item.type)) { if (item.type === FEED_ITEM_TYPE_ANNOTATION) { return this.getCommentFeedItemWithReplies<Annotation>(item, replies); } if (item.type === FEED_ITEM_TYPE_COMMENT) { return this.getCommentFeedItemWithReplies<Comment>(item, replies); } } return item; }); resolve(updatedFeedItems); }, error => { reject(error); }, ); }); }; /** * Network error callback * * @private * @param {Error} error - Error object * @param {Error} code - the code for the error * @param {Object} contextInfo - the context info for the error * @return {void} */ errorCallback = (error: ElementsXhrError, code: string, contextInfo: Object = {}): void => { /* eslint-disable no-console */ console.error(error); /* eslint-enable no-console */ // eslint-disable-next-line react/prop-types this.props.onError(error, code, contextInfo); }; /** * File approver contacts fetch success callback * * @private * @param {BoxItemCollection} collaborators - Collaborators response data * @return {void} */ getApproverContactsSuccessCallback = (collaborators: { entries: SelectorItems<> }): void => { const { entries } = collaborators; this.setState({ approverSelectorContacts: entries }); }; /** * File @mention contacts fetch success callback * * @private * @param {BoxItemCollection} collaborators - Collaborators response data * @return {void} */ getMentionContactsSuccessCallback = (collaborators: { entries: SelectorItems<> }): void => { const { entries } = collaborators; this.setState({ contactsLoaded: false }, () => this.setState({ contactsLoaded: true, mentionSelectorContacts: entries, }), ); }; /** * Fetches file @mention's with groups * * @private * @param {string} searchStr - Search string to filter file collaborators by * @return {void} */ getApprover = debounce((searchStr: string) => { const { file, api } = this.props; api.getFileCollaboratorsAPI(false).getCollaboratorsWithQuery( file.id, this.getApproverContactsSuccessCallback, this.errorCallback, searchStr, { includeGroups: true, }, ); }, DEFAULT_COLLAB_DEBOUNCE); /** * Fetches file @mention's * * @private * @param {string} searchStr - Search string to filter file collaborators by * @return {void} */ getMention = debounce((searchStr: string) => { const { file, api } = this.props; api.getFileCollaboratorsAPI(false).getCollaboratorsWithQuery( file.id, this.getMentionContactsSuccessCallback, this.errorCallback, searchStr, ); }, DEFAULT_COLLAB_DEBOUNCE); /** * Returns feed item based on the item id * * @param {FeedItems} feedItems - the feed items * @param {string} itemId - feed item id * @return {FeedItem | undefined} */ getFocusableFeedItemById = (feedItems: FeedItems, itemId?: string): FeedItem | typeof undefined => { if (!itemId) { return undefined; } return feedItems.find(({ id, type }) => id === itemId && this.isItemTypeFocusable(type)); }; /** * Returns parent feed item based on the reply id * * @param {FeedItems} feedItems - the feed items * @param {string} replyId - feed item's reply id * @return {FeedItem | undefined} */ getCommentFeedItemByReplyId = (feedItems: FeedItems, replyId?: string): FeedItem | typeof undefined => { if (!replyId) { return undefined; } return feedItems.find(item => { if ((item.type !== FEED_ITEM_TYPE_ANNOTATION && item.type !== FEED_ITEM_TYPE_COMMENT) || !item.replies) { return false; } return item.replies.some(({ id }) => id === replyId); }); }; /** * Returns true if item (based on given item id) is found within feed items or its replies and it, or its parent, can be active (focusable) * * @param {FeedItems} feedItems - the feed items * @param {string} itemId - feed item id * @return {boolean} */ isActiveEntryInFeed = (feedItems: FeedItems, itemId: string): boolean => !!(this.getFocusableFeedItemById(feedItems, itemId) || this.getCommentFeedItemByReplyId(feedItems, itemId)); isItemTypeFocusable = (type?: FeedItemType | FocusableFeedItem | CommentFeedItemType): boolean => type === FEED_ITEM_TYPE_ANNOTATION || type === FEED_ITEM_TYPE_COMMENT || type === FEED_ITEM_TYPE_TASK; isItemTypeComment = (type?: FeedItemType | FocusableFeedItem | CommentFeedItemType): boolean => type === FEED_ITEM_TYPE_ANNOTATION || type === FEED_ITEM_TYPE_COMMENT; /** * Returns active entry data (id, type) based on the activeFeedEntryId and activeFeedEntryType values * (it can be existing item or parent if the active entry id belongs to a reply) * * @param {FeedItems} feedItems - the feed items * @return {Promise<{ id: string, type?: FocusableFeedItemType }>} */ getActiveFeedEntryData = (feedItems: FeedItems): Promise<{ id?: string, type?: FeedItemType }> => { const { activeFeedEntryId, activeFeedEntryType, api, file } = this.props; return new Promise((resolve, reject) => { if (!activeFeedEntryId || !activeFeedEntryType || !this.isItemTypeFocusable(activeFeedEntryType)) { resolve({}); return; } // Check if the active entry is a first level Feed item const firstLevelItem = this.getFocusableFeedItemById(feedItems, activeFeedEntryId); if (firstLevelItem) { const { id, type } = firstLevelItem; resolve({ id, type }); return; } // Check if the active entry is within replies of any first level Feed items const firstLevelItemWithActiveReply = this.getCommentFeedItemByReplyId(feedItems, activeFeedEntryId); if (firstLevelItemWithActiveReply) { const { id, type } = firstLevelItemWithActiveReply; resolve({ id, type }); return; } // If the active entry could not be found within feed items, it's most likely a reply that // is not yet visible in feed and we need to fetch its data in order to find parent api.getFeedAPI(false).fetchThreadedComment( file, activeFeedEntryId, ({ parent }) => { const parentItem = this.getFocusableFeedItemById(feedItems, parent?.id); const { id, type } = parentItem || {}; resolve({ id, type }); }, (error: ElementsXhrError) => { if (error.status === 404) { resolve({}); } else { reject(error); } }, ); }); }; getActiveCommentPath(commentId?: string): string { if (!commentId) { return '/activity'; } return generatePath('/:sidebar/comments/:commentId?', { sidebar: 'activity', commentId, }); } /** * Fetches replies (comments) of a comment or annotation * * @param {string} id - id of the feed item * @param {CommentFeedItemType} type - type of the feed item * @return {void} */ getReplies = (id: string, type: CommentFeedItemType): void => { const { api, file } = this.props; api.getFeedAPI(false).fetchReplies(file, id, type, this.feedSuccessCallback, this.feedErrorCallback); // need to load the pending item this.fetchFeedItems(); }; /** * Gets the user avatar URL * * @param {string} userId the user id * @param {string} fileId the file id * @return the user avatar URL string for a given user with access token attached */ getAvatarUrl = async (userId: string): Promise<?string> => { const { file, api } = this.props; return api.getUsersAPI(false).getAvatarUrlWithAccessToken(userId, file.id); }; handleAnnotationSelect = (annotation: Annotation): void => { const { file_version, id: nextActiveAnnotationId } = annotation; const { emitActiveAnnotationChangeEvent, file, getAnnotationsMatchPath, getAnnotationsPath, history, internalSidebarNavigation, internalSidebarNavigationHandler, location, onAnnotationSelect, routerDisabled, } = this.props; const annotationFileVersionId = getProp(file_version, 'id'); const currentFileVersionId = getProp(file, 'file_version.id'); let selectedFileVersionId = currentFileVersionId; if (routerDisabled && internalSidebarNavigation) { selectedFileVersionId = getProp(internalSidebarNavigation, 'fileVersionId', currentFileVersionId); } else { const match = getAnnotationsMatchPath(location); selectedFileVersionId = getProp(match, 'params.fileVersionId', currentFileVersionId); } emitActiveAnnotationChangeEvent(nextActiveAnnotationId); if (annotationFileVersionId && annotationFileVersionId !== selectedFileVersionId) { if (routerDisabled && internalSidebarNavigationHandler) { internalSidebarNavigationHandler({ sidebar: ViewType.ACTIVITY, activeFeedEntryId: nextActiveAnnotationId, activeFeedEntryType: FeedEntryType.ANNOTATIONS, fileVersionId: annotationFileVersionId, }); } else { history.push(getAnnotationsPath(annotationFileVersionId, nextActiveAnnotationId)); } } onAnnotationSelect(annotation); }; handleItemsFiltered = (status?: ActivityFilterItemType) => { const { onFilterChange } = this.props; this.setState({ feedItemsStatusFilter: status }); onFilterChange(status); }; getFilteredFeedItems = (): FeedItems | typeof undefined => { const { feedItems, feedItemsStatusFilter } = this.state; if (!feedItems || !feedItemsStatusFilter || feedItemsStatusFilter === ACTIVITY_FILTER_OPTION_ALL) { return feedItems; } // Filter is completed on two properties (status and type) because filtering on comments (resolved vs. unresolved) // requires looking at item status to see if it is open or resolved. To filter all tasks, we need to look at the // item type. Item type is also used to keep versions in the feed. Task also has a status but it's status will be // "NOT_STARTED" or "COMPLETED" so it will not conflict with comment's status. return feedItems.filter(item => { return ( item.status === feedItemsStatusFilter || item.type === FEED_ITEM_TYPE_VERSION || item.type === feedItemsStatusFilter ); }); }; onTaskModalClose = () => { this.setState({ approverSelectorContacts: [], }); }; refresh(shouldRefreshCache: boolean = true): void { this.fetchFeedItems(shouldRefreshCache); } renderAddTaskButton = () => { const { isDisabled, hasTasks, internalSidebarNavigation, internalSidebarNavigationHandler, routerDisabled } = this.props; const { approverSelectorContacts } = this.state; const { getApprover, getAvatarUrl, createTask, onTaskModalClose } = this; if (!hasTasks) { return null; } return ( <AddTaskButton internalSidebarNavigation={internalSidebarNavigation} internalSidebarNavigationHandler={internalSidebarNavigationHandler} isDisabled={isDisabled} onTaskModalClose={onTaskModalClose} routerDisabled={routerDisabled} taskFormProps={{ approvers: [], approverSelectorContacts, completionRule: TASK_COMPLETION_RULE_ALL, createTask, getApproverWithQuery: getApprover, getAvatarUrl, id: '', message: '', }} /> ); }; renderActivitySidebarFilter = () => { const { features, hasTasks } = this.props; const { feedItemsStatusFilter } = this.state; const shouldShowActivityFeedFilter = isFeatureEnabled(features, 'activityFeed.filter.enabled'); const shouldShowAdditionalFilterOptions = isFeatureEnabled(features, 'activityFeed.newThreadedReplies.enabled'); if (!shouldShowActivityFeedFilter) { return null; } const activityFilterOptions: ActivityFilterOption[] = [ ACTIVITY_FILTER_OPTION_ALL, ACTIVITY_FILTER_OPTION_UNRESOLVED, ]; if (shouldShowAdditionalFilterOptions) { // Determine which filter options to show based on what activity types are available in current context activityFilterOptions.push(ACTIVITY_FILTER_OPTION_RESOLVED); if (hasTasks) { activityFilterOptions.push(ACTIVITY_FILTER_OPTION_TASKS); } } return ( <ActivitySidebarFilter activityFilterOptions={activityFilterOptions} feedItemType={feedItemsStatusFilter} onFeedItemTypeClick={selectedStatus => { this.handleItemsFiltered(selectedStatus); }} /> ); }; renderActions = () => ( <> {this.renderActivitySidebarFilter()} {this.renderAddTaskButton()} </> ); renderTitle = () => { const { features } = this.props; const shouldHideTitle = isFeatureEnabled(features, 'activityFeed.filter.enabled'); if (shouldHideTitle) { return null; } return <FormattedMessage {...messages.sidebarActivityTitle} />; }; render() { const { activeFeedEntryId, activeFeedEntryType, currentUser, currentUserError, elementId, features, file, hasReplies, hasVersions, isDisabled = false, onVersionHistoryClick, getUserProfileUrl, onTaskView, } = this.props; const { activityFeedError, approverSelectorContacts, contactsLoaded, mentionSelectorContacts } = this.state; const isNewThreadedRepliesEnabled = isFeatureEnabled(features, 'activityFeed.newThreadedReplies.enabled'); const shouldUseUAA = isFeatureEnabled(features, 'activityFeed.uaaIntegration.enabled'); return ( <SidebarContent actions={this.renderActions()} className={classNames('bcs-activity', { 'bcs-activity--full': hasReplies })} elementId={elementId} sidebarView={SIDEBAR_VIEW_ACTIVITY} title={this.renderTitle()} > <ActivityFeed activeFeedEntryId={activeFeedEntryId} activeFeedEntryType={activeFeedEntryType} activityFeedError={activityFeedError} approverSelectorContacts={approverSelectorContacts} currentUser={currentUser} currentUserError={currentUserError} feedItems={this.getFilteredFeedItems()} file={file} getApproverWithQuery={this.getApprover} getAvatarUrl={this.getAvatarUrl} getMentionWithQuery={this.getMention} getUserProfileUrl={getUserProfileUrl} hasNewThreadedReplies={isNewThreadedRepliesEnabled} hasReplies={hasReplies} hasVersions={hasVersions} isDisabled={isDisabled} mentionSelectorContacts={mentionSelectorContacts} contactsLoaded={contactsLoaded} onAnnotationDelete={this.handleAnnotationDelete} onAnnotationEdit={this.handleAnnotationEdit} onAnnotationSelect={this.handleAnnotationSelect} onAnnotationStatusChange={this.handleAnnotationStatusChange} onAppActivityDelete={this.deleteAppActivity} onCommentCreate={this.createComment} onCommentDelete={this.deleteComment} onCommentUpdate={this.updateComment} onHideReplies={this.updateReplies} onReplyCreate={this.createReply} onReplyDelete={this.deleteReply} onReplyUpdate={this.updateReply} onShowReplies={this.getReplies} onTaskAssignmentUpdate={this.updateTaskAssignment} onTaskCreate={this.createTask} onTaskDelete={this.deleteTask} onTaskModalClose={this.onTaskModalClose} onTaskUpdate={this.updateTask} onTaskView={onTaskView} onVersionHistoryClick={onVersionHistoryClick} shouldUseUAA={shouldUseUAA} /> </SidebarContent> ); } } export type ActivitySidebarProps = ExternalProps; export { ActivitySidebar as ActivitySidebarComponent }; export default flow([ withLogger(ORIGIN_ACTIVITY_SIDEBAR), withErrorBoundary(ORIGIN_ACTIVITY_SIDEBAR), withAPIContext, withFeatureConsumer, withAnnotatorContext, withRouterAndRef, ])(ActivitySidebar);