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
136 changes: 90 additions & 46 deletions src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { FormattedMessage, useIntl } from 'react-intl';

import { ActivityFeed, useActivityFeedScroll } from '@box/activity-feed';

import TaskModal from '../TaskModal';
import TaskModalV2 from './task-modal-v2';

import FeedItemRow from './FeedItemRow';
import { serializeEditorContent } from './helpers';
Expand All @@ -21,11 +21,14 @@ import { useAvatarUrls } from './useAvatarUrls';
import { useTimeFormat } from './useTimeFormat';
import { useVideoTimestamp } from './useVideoTimestamp';

import type { TaskFormV2SubmitPayload } from './task-modal-v2/types';
import type { ActivityFeedV2Props, TransformedFeedItem, UserContact } from './types';
import type { TaskAssigneeCollection, TaskNew } from '../../../common/types/tasks';
import type { ElementsXhrError } from '../../../common/types/api';
import type { GroupMini, SelectorItem, UserMini } from '../../../common/types/core';
import type { TaskAssigneeCollection, TaskNew, TaskType, TaskUpdatePayload } from '../../../common/types/tasks';

import { FILE_EXTENSIONS } from '../../common/item/constants';
import { TASK_COMPLETION_RULE_ALL, TASK_EDIT_MODE_EDIT, TASK_TYPE_APPROVAL } from '../../../constants';
import { TASK_TYPE_APPROVAL } from '../../../constants';

import commonMessages from '../../common/messages';
import draftJsMentionSelectorMessages from '../../../components/form-elements/draft-js-mention-selector/messages';
Expand All @@ -35,12 +38,10 @@ import './ActivityFeedV2.scss';

const ActivityFeedV2 = ({
activeFeedEntryId,
approverSelectorContacts = [],
createTask,
currentUser,
feedItems,
file,
getApproverWithQuery,
getAvatarUrl,
getMentionAsync,
getTaskCollaborators,
Expand Down Expand Up @@ -176,14 +177,14 @@ const ActivityFeedV2 = ({
};

const [isTaskFormOpen, setIsTaskFormOpen] = React.useState(false);
const [taskType, setTaskType] = React.useState<string>(TASK_TYPE_APPROVAL);
const [taskError, setTaskError] = React.useState<Error | null>(null);
const [taskType, setTaskType] = React.useState<TaskType>(TASK_TYPE_APPROVAL);
const [taskError, setTaskError] = React.useState<ElementsXhrError | undefined>(undefined);
const [editingTask, setEditingTask] = React.useState<TaskNew | null>(null);
const [editingAssignees, setEditingAssignees] = React.useState<TaskAssigneeCollection | null>(null);

const handleTaskModalClose = React.useCallback(() => {
setIsTaskFormOpen(false);
setTaskError(null);
setTaskError(undefined);
setEditingTask(null);
setEditingAssignees(null);
}, []);
Expand Down Expand Up @@ -277,16 +278,22 @@ const ActivityFeedV2 = ({
}
}, [activeFeedEntryId, filteredItems, scrollHandle]);

// Scroll only to comments/annotations the current user authored after the post
// Scroll only to comments/annotations/tasks the current user authored after the post
Comment thread
jackiejou marked this conversation as resolved.
// snapshot, so a concurrent push from another user doesn't hijack the viewport.
React.useEffect(() => {
const knownIds = knownIdsBeforePostRef.current;
if (!knownIds || !scrollHandle || !currentUserId) return;
const newItem = filteredItems.find(item => {
if (knownIds.has(item.id)) return false;
if (item.type !== 'comment' && item.type !== 'annotation') return false;
const author = item.messages[0]?.author;
return author ? String(author.id) === currentUserId : false;
if (item.type === 'comment' || item.type === 'annotation') {
const author = item.messages[0]?.author;
return author ? String(author.id) === currentUserId : false;
}
if (item.type === 'task') {
const authorId = item.originalTask?.created_by?.target?.id;
return authorId != null && String(authorId) === currentUserId;
}
return false;
});
if (!newItem) return;
if (scrollHandle.scrollTo(newItem.id)) {
Expand Down Expand Up @@ -384,6 +391,48 @@ const ActivityFeedV2 = ({
[allowVideoTimestamps, filteredItems, fileVersionId, isTimestampPressed, onCommentCreate, timestampMs],
);

const handleCreateTask = React.useCallback(
(
text: string,
approvers: SelectorItem<UserMini | GroupMini>[],
type: TaskType,
dueDate: string | null,
completionRule: TaskFormV2SubmitPayload['completionRule'],
onSuccess: () => void,
onError: (error: ElementsXhrError) => void,
) => {
if (!createTask) {
onError({ status: 0, code: 'create_task_unavailable' } as ElementsXhrError);
return;
}
const snapshot = new Set(filteredItems.map(item => item.id));
createTask(
text,
approvers,
type,
dueDate,
completionRule,
() => {
knownIdsBeforePostRef.current = snapshot;
onSuccess();
},
onError,
);
},
[createTask, filteredItems],
);

const handleEditTask = React.useCallback(
(payload: TaskUpdatePayload, onSuccess: () => void, onError: (error: ElementsXhrError) => void) => {
if (!onTaskUpdate) {
onError({ status: 0, code: 'edit_task_unavailable' } as ElementsXhrError);
return;
}
onTaskUpdate(payload, onSuccess, onError);
},
[onTaskUpdate],
);

return (
<div className="bcs-NewActivityFeed" data-resin-feature="activityfeedv2">
<ActivityFeed.Root mentionContext={mentionContext} scrollTo={scrollHandle}>
Expand Down Expand Up @@ -453,40 +502,35 @@ const ActivityFeedV2 = ({
/>
</div>
</ActivityFeed.Root>
<TaskModal
editMode={editingTask ? TASK_EDIT_MODE_EDIT : undefined}
error={taskError}
isTaskFormOpen={isTaskFormOpen}
onModalClose={handleTaskModalClose}
onSubmitError={setTaskError}
onSubmitSuccess={handleTaskModalClose}
taskFormProps={
editingTask
? {
approvers: editingAssignees?.entries ?? [],
approverSelectorContacts,
completionRule: editingTask.completion_rule,
createTask: noop,
dueDate: editingTask.due_at,
editTask: onTaskUpdate,
getApproverWithQuery,
getAvatarUrl,
id: editingTask.id,
message: editingTask.description,
}
: {
approvers: [],
approverSelectorContacts,
completionRule: TASK_COMPLETION_RULE_ALL,
createTask,
getApproverWithQuery,
getAvatarUrl,
id: '',
message: '',
}
}
taskType={taskType}
/>
{editingTask ? (
<TaskModalV2
createTask={handleCreateTask}
editingAssignees={editingAssignees?.entries ?? []}
editingTask={editingTask}
editTask={handleEditTask}
error={taskError}
fetchAvatarUrls={fetchAvatarUrls}
fetchUsers={fetchUsers}
isOpen={isTaskFormOpen}
mode="edit"
onClose={handleTaskModalClose}
onSubmitError={setTaskError}
onSubmitSuccess={handleTaskModalClose}
taskType={taskType}
/>
) : (
<TaskModalV2
createTask={handleCreateTask}
error={taskError}
fetchAvatarUrls={fetchAvatarUrls}
fetchUsers={fetchUsers}
isOpen={isTaskFormOpen}
onClose={handleTaskModalClose}
onSubmitError={setTaskError}
onSubmitSuccess={handleTaskModalClose}
taskType={taskType}
/>
)}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { ActivityFeed } from '@box/activity-feed';
import { act, render, screen } from '../../../../test-utils/testing-library';
import ActivityFeedV2 from '..';
import type { ActivityFeedV2Props } from '../ActivityFeedV2';
import type { TaskModalV2Props } from '../task-modal-v2';
import type { CreateTaskCallback } from '../task-modal-v2/types';

type EditorProps = React.ComponentProps<typeof ActivityFeed.Editor>;

Expand All @@ -29,6 +31,15 @@ let lastFilterMenuProps: FilterMenuProps = {};
let lastShowResolvedOptionProps: FilterOptionProps = {};
let lastMentionMeOptionProps: FilterOptionProps = {};
let lastEditorProps: Partial<EditorProps> = {};
let lastTaskModalProps: Partial<TaskModalV2Props> = {};

jest.mock('../task-modal-v2', () => ({
__esModule: true,
default: (props: Partial<TaskModalV2Props>) => {
lastTaskModalProps = props;
return props.isOpen ? <div data-testid="task-modal-v2" /> : null;
},
}));

jest.mock('@box/activity-feed', () => {
const actual = jest.requireActual('@box/activity-feed');
Expand Down Expand Up @@ -170,6 +181,7 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => {
lastShowResolvedOptionProps = {};
lastMentionMeOptionProps = {};
lastEditorProps = {};
lastTaskModalProps = {};
mockSerializeMentionMarkup.mockImplementation((doc: unknown) => ({
hasMention: false,
text: JSON.stringify(doc),
Expand Down Expand Up @@ -1156,4 +1168,158 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => {
expect(mockViewer.emit).toHaveBeenCalledWith('comment_markers', []);
});
});

describe('task modal wiring', () => {
type CreateTaskParams = Parameters<CreateTaskCallback>;
type OnSubmitErrorParam = Parameters<NonNullable<TaskModalV2Props['onSubmitError']>>[0];

const currentUser: ActivityFeedV2Props['currentUser'] = { id: 'user-1', name: 'Me', type: 'user' };
const submitTaskModalArgs: [
CreateTaskParams[0],
CreateTaskParams[1],
CreateTaskParams[2],
CreateTaskParams[3],
CreateTaskParams[4],
] = ['msg', [], 'APPROVAL', null, 'ALL_ASSIGNEES'];

const buildTaskAuthoredBy = (userId: string, id: string) => ({
...mockTask,
created_by: {
...mockTask.created_by,
target: { ...mockTask.created_by.target, id: userId },
},
id,
});

const createTaskThatSucceeds = () =>
jest.fn<ReturnType<CreateTaskCallback>, CreateTaskParams>(
(_text, _approvers, _type, _due, _rule, onSuccess) => onSuccess(),
);

const renderFeed = (props: Partial<ActivityFeedV2Props> = {}) =>
render(
<ActivityFeedV2
currentUser={currentUser}
feedItems={[mockComment] as ActivityFeedV2Props['feedItems']}
{...props}
/>,
);

const submitTaskModal = (onSuccess = jest.fn(), onError = jest.fn()) => {
act(() => {
lastTaskModalProps.createTask?.(...submitTaskModalArgs, onSuccess, onError);
});
return { onError, onSuccess };
};

test('should forward create-mode props to TaskModalV2', () => {
renderFeed({ createTask: jest.fn(), onTaskUpdate: jest.fn() });
expect(lastTaskModalProps.mode).toBeUndefined();
expect(lastTaskModalProps.editingTask).toBeUndefined();
expect(lastTaskModalProps.taskType).toBe('APPROVAL');
expect(lastTaskModalProps.isOpen).toBe(false);
});

test('should forward createTask arguments and callbacks from the modal to the parent', () => {
const createTask = jest.fn();
renderFeed({ createTask });
const { onError } = submitTaskModal();

expect(createTask).toHaveBeenCalledTimes(1);
const [text, approvers, type, dueDate, rule, onSuccessArg, onErrorArg] = createTask.mock.calls[0];
expect(text).toBe('msg');
expect(approvers).toEqual([]);
expect(type).toBe('APPROVAL');
expect(dueDate).toBeNull();
expect(rule).toBe('ALL_ASSIGNEES');
expect(typeof onSuccessArg).toBe('function');
expect(onErrorArg).toBe(onError);
});

test('should invoke onError when createTask is not wired', () => {
renderFeed();
const { onSuccess, onError } = submitTaskModal();
expect(onSuccess).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ code: 'create_task_unavailable', status: 0 }),
);
});

test('should surface onSubmitError value on the modal error prop', () => {
renderFeed({ createTask: jest.fn() });
expect(lastTaskModalProps.error).toBeUndefined();
const apiError: OnSubmitErrorParam = {
code: 'server_error',
context_info: {},
help_url: '',
message: 'kaboom',
request_id: 'req-1',
status: 500,
type: 'error',
};
act(() => {
lastTaskModalProps.onSubmitError?.(apiError);
});
expect(lastTaskModalProps.error).toEqual(apiError);
});

test('should scroll to a newly created task authored by the current user', () => {
const createTask = createTaskThatSucceeds();
const newUserTask = buildTaskAuthoredBy('user-1', 'new-task');
const { rerender } = renderFeed({ createTask });
mockScrollTo.mockClear();

submitTaskModal();
expect(mockScrollTo).not.toHaveBeenCalled();

rerender(
<ActivityFeedV2
createTask={createTask}
currentUser={currentUser}
feedItems={[mockComment, newUserTask] as ActivityFeedV2Props['feedItems']}
/>,
);
expect(mockScrollTo).toHaveBeenLastCalledWith('new-task');
});

test('should not scroll to a task authored by another user after create', () => {
const createTask = createTaskThatSucceeds();
const strangerTask = buildTaskAuthoredBy('stranger', 'stranger-task');
const { rerender } = renderFeed({ createTask });
mockScrollTo.mockClear();

submitTaskModal();
rerender(
<ActivityFeedV2
createTask={createTask}
currentUser={currentUser}
feedItems={[mockComment, strangerTask] as ActivityFeedV2Props['feedItems']}
/>,
);
expect(mockScrollTo).not.toHaveBeenCalled();
});

test('should not throw when a task item is missing created_by.target', () => {
const createTask = createTaskThatSucceeds();
const brokenTask = {
...mockTask,
created_by: { ...mockTask.created_by, target: undefined },
id: 'broken-task',
};
const { rerender } = renderFeed({ createTask });
mockScrollTo.mockClear();

submitTaskModal();
expect(() =>
rerender(
<ActivityFeedV2
createTask={createTask}
currentUser={currentUser}
feedItems={[mockComment, brokenTask] as ActivityFeedV2Props['feedItems']}
/>,
),
).not.toThrow();
expect(mockScrollTo).not.toHaveBeenCalled();
});
});
});
Loading