Skip to content

Commit f9d4130

Browse files
authored
feat(threaded-replies): Create Reply button component (#3298)
* feat(threaded-replies): Create reply button * feat(threaded-replies): Update CreateReply props * feat(threaded-replies): Add tests and update isDisabled * feat(threaded-replies): Fix lint errors * feat(threaded-replies): sort imports * feat(threaded-replies): Remove getAvatarUrl conditional * feat(threaded-replies): rename test * feat(threaded-replies): Allow placeholder in props
1 parent a893e5a commit f9d4130

6 files changed

Lines changed: 237 additions & 3 deletions

File tree

‎i18n/en-US.properties‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ be.contentSidebar.activityFeed.comment.commentPostedFullDateTime = {time, date,
176176
be.contentSidebar.activityFeed.comment.commentResolveMenuItem = Resolve
177177
# Text to show on menu item to unresolve the comment
178178
be.contentSidebar.activityFeed.comment.commentUnresolveMenuItem = Unresolve
179+
# Text to show on button to start replying to comment
180+
be.contentSidebar.activityFeed.comment.reply = Reply
181+
# Text to show on reply form input placeholder
182+
be.contentSidebar.activityFeed.comment.replyInThread = Reply in thread
179183
# Placeholder for approvers input
180184
be.contentSidebar.activityFeed.commentForm.approvalAddAssignee = Add an assignee
181185
# Label for checkbox to add approvers to a comment

‎src/elements/content-sidebar/activity-feed/comment-form/CommentForm.js‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ type Props = {
2727
contactsLoaded?: boolean,
2828
createComment?: Function,
2929
entityId?: string,
30-
getAvatarUrl: GetAvatarUrlCallback,
30+
getAvatarUrl?: GetAvatarUrlCallback,
3131
getMentionWithQuery?: Function,
3232
isDisabled?: boolean,
3333
isEditing?: boolean,
@@ -40,7 +40,7 @@ type Props = {
4040
showTip?: boolean,
4141
tagged_message?: string,
4242
updateComment?: Function,
43-
user: User,
43+
user?: User,
4444
} & InjectIntlProvidedProps;
4545

4646
type State = {
@@ -129,7 +129,7 @@ class CommentForm extends React.Component<Props, State> {
129129

130130
return (
131131
<Media className={inputContainerClassNames}>
132-
{!isEditing && (
132+
{!isEditing && !!user && (
133133
<Media.Figure className="bcs-CommentForm-avatar">
134134
<Avatar getAvatarUrl={getAvatarUrl} user={user} />
135135
</Media.Figure>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// @flow
2+
3+
import * as React from 'react';
4+
import { FormattedMessage, injectIntl } from 'react-intl';
5+
import type { IntlShape } from 'react-intl';
6+
import noop from 'lodash/noop';
7+
8+
import ArrowArcRight from '../../../../icon/fill/ArrowArcRight';
9+
import PlainButton from '../../../../components/plain-button';
10+
11+
import type { SelectorItems } from '../../../../common/types/core';
12+
13+
import CommentForm from '../comment-form';
14+
15+
import messages from './messages';
16+
import './CreateReply.scss';
17+
18+
type Props = {
19+
getMentionWithQuery?: (searchStr: string) => void,
20+
intl: IntlShape,
21+
isDisabled?: boolean,
22+
mentionSelectorContacts?: SelectorItems<>,
23+
onCancel: () => void,
24+
onClick: () => void,
25+
onFocus?: () => void,
26+
onSubmit: (reply: string) => void,
27+
placeholder?: string,
28+
showReplyForm: boolean,
29+
};
30+
31+
const CreateReply = ({
32+
mentionSelectorContacts,
33+
getMentionWithQuery,
34+
isDisabled = false,
35+
intl,
36+
onFocus = noop,
37+
onCancel,
38+
onSubmit,
39+
onClick,
40+
placeholder = intl.formatMessage(messages.replyInThread),
41+
showReplyForm,
42+
}: Props) => {
43+
const handleSubmit = ({ text }: { text: string }) => {
44+
onSubmit(text);
45+
};
46+
47+
return (
48+
<div className="bcs-CreateReply">
49+
{showReplyForm && !isDisabled ? (
50+
<CommentForm
51+
className="bcs-CreateReply-form"
52+
isOpen
53+
isEditing
54+
showTip={false}
55+
onCancel={onCancel}
56+
onFocus={onFocus}
57+
createComment={handleSubmit}
58+
mentionSelectorContacts={mentionSelectorContacts}
59+
getMentionWithQuery={getMentionWithQuery}
60+
placeholder={placeholder}
61+
/>
62+
) : (
63+
<PlainButton className="bcs-CreateReply-toggle" onClick={onClick} type="button" isDisabled={isDisabled}>
64+
<ArrowArcRight className="bcs-CreateReply-arrow" />
65+
<FormattedMessage {...messages.reply} />
66+
</PlainButton>
67+
)}
68+
</div>
69+
);
70+
};
71+
72+
export default injectIntl(CreateReply);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
@import '../../../common/variables';
2+
3+
.bcs-CreateReply {
4+
.bcs-CreateReply-toggle {
5+
display: flex;
6+
align-items: center;
7+
color: $bdl-gray-62;
8+
font-weight: bold;
9+
10+
.bcs-CreateReply-arrow {
11+
margin-right: $bdl-grid-unit;
12+
// in design arrow is turning left, but we only have ArrowArcRight, so will transfrom it to left one
13+
transform: matrix(-1, 0, 0, 1, 0, 0);
14+
}
15+
}
16+
17+
.bcs-CreateReply-form {
18+
padding: 0;
19+
border-top: none;
20+
}
21+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// @flow
2+
3+
import React from 'react';
4+
import { IntlProvider } from 'react-intl';
5+
import { fireEvent, render, screen } from '@testing-library/react';
6+
import { ContentState, EditorState } from 'draft-js';
7+
import CreateReply from '../CreateReply';
8+
import localize from '../../../../../../test/support/i18n';
9+
import messages from '../messages';
10+
11+
jest.mock('react-intl', () => ({
12+
...jest.requireActual('react-intl'),
13+
FormattedMessage: ({ defaultMessage }: { defaultMessage: string }) => <span>{defaultMessage}</span>,
14+
}));
15+
16+
const onCancel = jest.fn();
17+
const onFocus = jest.fn();
18+
const onSubmit = jest.fn();
19+
const onClick = jest.fn();
20+
21+
const replyInThreadMessage = localize(messages.replyInThread.id);
22+
const replyMessage = localize(messages.reply.id);
23+
24+
const getWrapper = props =>
25+
render(
26+
<IntlProvider locale="en">
27+
<CreateReply
28+
isDisabled={false}
29+
onCancel={onCancel}
30+
onFocus={onFocus}
31+
onSubmit={onSubmit}
32+
onClick={onClick}
33+
showReplyForm={false}
34+
{...props}
35+
/>
36+
</IntlProvider>,
37+
);
38+
39+
describe('elements/content-sidebar/ActivityFeed/comment/CreateReply', () => {
40+
afterEach(() => {
41+
jest.restoreAllMocks();
42+
});
43+
44+
test('should correctly render CreateReply button', () => {
45+
getWrapper();
46+
47+
expect(screen.getByText(replyMessage)).toBeVisible();
48+
expect(screen.queryByText(replyInThreadMessage)).not.toBeInTheDocument();
49+
});
50+
51+
test('should correctly render CreateReply form with default placeholder', () => {
52+
getWrapper({ showReplyForm: true });
53+
54+
expect(screen.getByText(replyInThreadMessage)).toBeVisible();
55+
expect(screen.queryByText(replyMessage)).not.toBeInTheDocument();
56+
});
57+
58+
test('should correctly render CreateReply form with a passed in placeholder', () => {
59+
getWrapper({ showReplyForm: true, placeholder: 'Reply to Task' });
60+
61+
expect(screen.getByText('Reply to Task')).toBeVisible();
62+
expect(screen.queryByText(replyInThreadMessage)).not.toBeInTheDocument();
63+
expect(screen.queryByText(replyMessage)).not.toBeInTheDocument();
64+
});
65+
66+
test('should disable Reply button if isDisabled property is true', () => {
67+
getWrapper({ isDisabled: true });
68+
69+
const replyButton = screen.getByRole('button', { name: replyMessage });
70+
expect(replyButton).toHaveAttribute('aria-disabled', 'true');
71+
72+
fireEvent.click(replyButton);
73+
expect(onClick).not.toBeCalled();
74+
});
75+
76+
test('should call onClick when reply button is clicked', () => {
77+
getWrapper();
78+
79+
fireEvent.click(screen.getByText(replyMessage));
80+
expect(onClick).toBeCalledTimes(1);
81+
expect(onSubmit).not.toBeCalled();
82+
expect(onFocus).not.toBeCalled();
83+
expect(onCancel).not.toBeCalled();
84+
});
85+
86+
test('should not show form when isDisabled property is true', () => {
87+
getWrapper({ showReplyForm: true, isDisabled: true });
88+
89+
const replyButton = screen.getByRole('button', { name: replyMessage });
90+
expect(screen.queryByText(replyInThreadMessage)).not.toBeInTheDocument();
91+
expect(replyButton).toHaveAttribute('aria-disabled', 'true');
92+
93+
fireEvent.click(replyButton);
94+
expect(onClick).not.toBeCalled();
95+
});
96+
97+
test('should call onCancel when form cancel is clicked', () => {
98+
getWrapper({ showReplyForm: true });
99+
100+
fireEvent.click(screen.getByText('Cancel'));
101+
expect(onCancel).toBeCalledTimes(1);
102+
expect(onSubmit).not.toBeCalled();
103+
expect(onFocus).not.toBeCalled();
104+
expect(onClick).not.toBeCalled();
105+
});
106+
107+
test('should call onSubmit when reply is posted', () => {
108+
// Mock DraftJS editor and intercept onChange since DraftJS doesn't have a value setter
109+
const draftjs = require('draft-js');
110+
draftjs.Editor = jest.fn(props => {
111+
const modifiedOnchange = e => {
112+
const text = e.target.value;
113+
const content = ContentState.createFromText(text);
114+
props.onChange(EditorState.createWithContent(content));
115+
};
116+
return <input className="editor" onChange={e => modifiedOnchange(e)} />;
117+
});
118+
119+
getWrapper({ showReplyForm: true });
120+
121+
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Batman' } });
122+
123+
fireEvent.click(screen.getByText('Post'));
124+
expect(onSubmit).toBeCalledTimes(1);
125+
expect(onSubmit).toBeCalledWith('Batman');
126+
});
127+
});

‎src/elements/content-sidebar/activity-feed/comment/messages.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ const messages = defineMessages({
3737
defaultMessage: 'Unresolve',
3838
description: 'Text to show on menu item to unresolve the comment',
3939
},
40+
reply: {
41+
id: 'be.contentSidebar.activityFeed.comment.reply',
42+
defaultMessage: 'Reply',
43+
description: 'Text to show on button to start replying to comment',
44+
},
45+
replyInThread: {
46+
id: 'be.contentSidebar.activityFeed.comment.replyInThread',
47+
defaultMessage: 'Reply in thread',
48+
description: 'Text to show on reply form input placeholder',
49+
},
4050
});
4151

4252
export default messages;

0 commit comments

Comments
 (0)