diff --git a/Intl/localizationData/en.js b/Intl/localizationData/en.js index 79b481d3c..414e00500 100644 --- a/Intl/localizationData/en.js +++ b/Intl/localizationData/en.js @@ -12,6 +12,24 @@ export default { postTitle: 'Post Title', postContent: 'Post Content', submit: 'Submit', + addComment: 'Add comment', + editComment: 'Edit comment', + deleteComment: 'Delete comment', + emptyComments: 'No comments added yet. Let\'s write something awesome!', + makeComment: `{count, plural, + =0 {Add first comment} + other {See all} + }`, + commentForm: { + author: { + label: 'Comment Author', + placeholder: 'Write your name here', + }, + content: { + label: 'Comment body', + placeholder: 'Place your thoughts here', + }, + }, comment: `user {name} {value, plural, =0 {does not have any comments} =1 {has # comment} diff --git a/Intl/localizationData/fr.js b/Intl/localizationData/fr.js index 7e5b81b3f..3f6c00a97 100644 --- a/Intl/localizationData/fr.js +++ b/Intl/localizationData/fr.js @@ -12,6 +12,24 @@ export default { postTitle: 'Titre de l\'article', postContent: 'Contenu après', submit: 'Soumettre', + addComent: 'Ajouter un commentaire', + editComment: 'Modifier le commentaire', + deleteComment: 'Supprimer le commentaire', + emptyComments: 'No comments added yet. Let\'s write something awesome!', + makeComment: `{count, plural, + =0 {Add first comment} + other {See all} + }`, + commentForm: { + author: { + label: 'Comment Author', + placeholder: 'Write your name here', + }, + content: { + label: 'Comment body', + placeholder: 'Place your thoughts here', + }, + }, comment: `user {name} {value, plural, =0 {does not have any comments} =1 {has # comment} diff --git a/client/main.css b/client/main.css index a61bc299f..d6ebc8f6d 100644 --- a/client/main.css +++ b/client/main.css @@ -32,3 +32,32 @@ body { font-family: 'Lato', sans-serif; font-size: 16px; } + +:global(.btn) { + display: flex; + justify-content: center; + align-items: center; + margin: 10px auto 0; + background: #41c3fa; + border: 1px solid #fafafa; + outline: none; + border-radius: 12px; + max-width: 240px; + width: 100%; + height: 40px; + font-size: 18px; + color: #fff; + box-shadow: 1px 2px 2px rgba(0, 0, 0, 0.15), inset 1px 2px rgba(255, 255, 255, 0.3); + transition: all 0.15s ease; + cursor: pointer; +} + +:global(.btn:hover:not(:disabled)) { + background: #548bfa; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15), inset 0 0 transparent; +} + +:global(.btn:disabled) { + background: #6bdcfa; + box-shadow: none; +} diff --git a/client/modules/App/AppReducer.js b/client/modules/App/AppReducer.js index ed3cf4827..d2fb8a688 100644 --- a/client/modules/App/AppReducer.js +++ b/client/modules/App/AppReducer.js @@ -4,6 +4,7 @@ import { TOGGLE_ADD_POST } from './AppActions'; // Initial State const initialState = { showAddPost: false, + showAddComment: false, }; const AppReducer = (state = initialState, action) => { diff --git a/client/modules/Comment/CommentFormWidget/CommentFormWidget.css b/client/modules/Comment/CommentFormWidget/CommentFormWidget.css new file mode 100644 index 000000000..b00f46d5f --- /dev/null +++ b/client/modules/Comment/CommentFormWidget/CommentFormWidget.css @@ -0,0 +1,11 @@ +.container { + padding: 50px; + margin: 0 -15px; + position: relative; +} + +.container.inline { + background: transparent; + padding-bottom: 15px; + padding-top: 25px; +} diff --git a/client/modules/Comment/CommentFormWidget/CommentFormWidget.js b/client/modules/Comment/CommentFormWidget/CommentFormWidget.js new file mode 100644 index 000000000..1065f6671 --- /dev/null +++ b/client/modules/Comment/CommentFormWidget/CommentFormWidget.js @@ -0,0 +1,110 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { FormattedMessage, injectIntl } from 'react-intl'; + +// Import styles +import styles from './CommentFormWidget.css'; + +// Import components +import CommentForm from '../components/CommentForm/CommentForm'; + +// Import actions +import { addCommentRequest, deleteCommentRequest, editCommentRequest } from '../CommentsActions'; +import { withRouter } from 'react-router'; + +export class CommentFormWidget extends React.Component { + constructor(props) { + super(props); + + this.state = { + editMode: !!props.initialValues, + isFormShown: false, + comment: props.initialValues, + }; + } + + componentDidUpdate(prevProps) { + if ( + prevProps.initialValues && this.props.initialValues && + (prevProps.initialValues.author !== this.props.initialValues.author || + prevProps.initialValues.content !== this.props.initialValues.content) + ) { + this.refreshComment(); + } + } + + showForm = () => this.setState({ isFormShown: true }); + + closeForm = () => this.setState( + { isFormShown: false }, + () => { + if (this.props.onClose) { + this.props.onClose(); + } + }, + ); + + refreshComment = () => this.setState({ comment: this.props.initialValues }); + + handleFormSubmit = (formData) => { + const { editComment, addComment, submitCallback, params } = this.props; + const { cuid: postId } = params; + if (!postId) { + formData.reject('PostId should be specified.'); + return; + } + + const payload = { ...formData, postId }; + + if (this.state.editMode) { + editComment(payload); + } else { + addComment(payload); + } + if (submitCallback) { + submitCallback(); + } + this.closeForm(); + }; + + render() { + const initialValues = this.state.comment || { author: '', content: '' }; + return this.props.inline || this.state.isFormShown ? ( +
+ +
+ ) : ; + } +} + +CommentFormWidget.propTypes = { + initialValues: PropTypes.shape({ + author: PropTypes.string, + content: PropTypes.string, + commentId: PropTypes.string, + }), + onClose: PropTypes.func, + inline: PropTypes.bool, + addComment: PropTypes.func, + editComment: PropTypes.func, + submitCallback: PropTypes.func, + params: PropTypes.object, +}; + +const mapStateToProps = () => ({}); + +const mapDispatchToProps = dispatch => ({ + addComment: (comment) => dispatch(addCommentRequest(comment)), + editComment: (comment) => dispatch(editCommentRequest(comment)), + deleteComment: (commentId) => dispatch(deleteCommentRequest(commentId)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(withRouter(CommentFormWidget))); diff --git a/client/modules/Comment/CommentList/CommentList.css b/client/modules/Comment/CommentList/CommentList.css new file mode 100644 index 000000000..f8dad7a76 --- /dev/null +++ b/client/modules/Comment/CommentList/CommentList.css @@ -0,0 +1,14 @@ +.container { + display: flex; + flex-flow: column; + align-items: stretch; + justify-content: flex-start; + margin: 25px auto 0; + max-width: 600px; + width: 100%; +} + +.no-comments { + margin-top: 20px; + text-align: center; +} diff --git a/client/modules/Comment/CommentList/CommentList.js b/client/modules/Comment/CommentList/CommentList.js new file mode 100644 index 000000000..22d3b5777 --- /dev/null +++ b/client/modules/Comment/CommentList/CommentList.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +// Import style +import styles from './CommentList.css'; + +// Import Components +import CommentListItem from '../components/CommentListItem/CommentListItem'; + +function CommentList({ comments, deleteComment }) { + return ( +
+ {comments && comments.length ? comments.map(comment => ( + + )) : ( +
+ +
+ )} +
+ ); +} + +CommentList.propTypes = { + comments: PropTypes.arrayOf(PropTypes.shape({ + cuid: PropTypes.string, + author: PropTypes.string, + content: PropTypes.string, + })), + deleteComment: PropTypes.func.isRequired, +}; + +export default CommentList; diff --git a/client/modules/Comment/CommentsActions.js b/client/modules/Comment/CommentsActions.js new file mode 100644 index 000000000..f1ae6d47b --- /dev/null +++ b/client/modules/Comment/CommentsActions.js @@ -0,0 +1,91 @@ +import callApi from '../../util/apiCaller'; + +// Export Constants +export const ADD_COMMENT_SUCCESS = 'ADD_COMMENT_SUCCESS'; +export const ADD_COMMENT_FAILURE = 'ADD_COMMENT_FAILURE'; + +export const EDIT_COMMENT_SUCCESS = 'EDIT_COMMENT_SUCCESS'; +export const EDIT_COMMENT_FAILURE = 'EDIT_COMMENT_FAILURE'; + +export const DELETE_COMMENT_SUCCESS = 'DELETE_COMMENT_SUCCESS'; +export const DELETE_COMMENT_FAILURE = 'DELETE_COMMENT_FAILURE'; + +// Export Actions +export function addCommentSuccess(comment) { + return { + type: ADD_COMMENT_SUCCESS, + comment, + }; +} + +export function addCommentFailure(error) { + return { + type: ADD_COMMENT_FAILURE, + error, + }; +} + +export function addCommentRequest({ comment, resolve, reject, postId }) { + return (dispatch) => { + return callApi(`comments/${postId}`, 'post', { comment }) + .then(res => { + if (resolve) resolve(); + dispatch(addCommentSuccess(res.comment)); + }) + .catch(error => { + if (reject) reject(error); + dispatch(addCommentFailure(error)); + }); + }; +} + +export function editCommentSuccess(comment) { + return { + type: EDIT_COMMENT_SUCCESS, + comment, + }; +} + +export function editCommentFailure(error) { + return { + type: EDIT_COMMENT_FAILURE, + error, + }; +} + +export function editCommentRequest({ comment, resolve, reject }) { + return (dispatch) => { + return callApi(`comments/${comment.cuid}`, 'put', { comment }) + .then(res => { + if (resolve) resolve(); + dispatch(editCommentSuccess(res.comment)); + }) + .catch(error => { + if (reject) reject(error); + dispatch(editCommentFailure(error)); + }); + }; +} + + +export function deleteCommentSuccess(comment) { + return { + type: DELETE_COMMENT_SUCCESS, + comment, + }; +} + +export function deleteCommentFailure(error) { + return { + type: DELETE_COMMENT_FAILURE, + error, + }; +} + +export function deleteCommentRequest(comment) { + return (dispatch) => { + return callApi(`comments/${comment.cuid}`, 'delete') + .then(() => dispatch(deleteCommentSuccess(comment))) + .catch(error => dispatch(deleteCommentFailure(error))); + }; +} diff --git a/client/modules/Comment/__tests__/CommentActions.spec.js b/client/modules/Comment/__tests__/CommentActions.spec.js new file mode 100644 index 000000000..583457b11 --- /dev/null +++ b/client/modules/Comment/__tests__/CommentActions.spec.js @@ -0,0 +1,58 @@ +import test from 'ava'; +import { actionTest } from 'redux-ava'; + +import { + ADD_COMMENT_SUCCESS, + ADD_COMMENT_FAILURE, + EDIT_COMMENT_SUCCESS, + EDIT_COMMENT_FAILURE, + DELETE_COMMENT_SUCCESS, + DELETE_COMMENT_FAILURE, + addCommentSuccess, + addCommentFailure, + editCommentSuccess, + editCommentFailure, + deleteCommentSuccess, + deleteCommentFailure, +} from '../CommentsActions'; + +const comment1 = { cuid: '1234567890', postId: 'f34gb2bh24b24b2', author: 'John Doe', content: 'John Doe said Boo!' }; +const comment2 = { cuid: '0987654321', postId: 'f34gb2bh24b24b2', author: 'Jane Doe', content: 'Jane Doe is laughing the table...' }; + +const error = new Error('Mock error'); + +test('should return the correct type for addCommentSuccess', actionTest( + addCommentSuccess, + comment1, + { type: ADD_COMMENT_SUCCESS, comment: comment1 }, +)); + +test('should return the correct type for addCommentFailure', actionTest( + addCommentFailure, + error, + { type: ADD_COMMENT_FAILURE, error }, +)); + +test('should return the correct type for editCommentSuccess', actionTest( + editCommentSuccess, + comment2, + { type: EDIT_COMMENT_SUCCESS, comment: comment2 }, +)); + +test('should return the correct type for editCommentFailure', actionTest( + editCommentFailure, + error, + { type: EDIT_COMMENT_FAILURE, error }, +)); + +test('should return the correct type for deleteCommentSuccess', actionTest( + deleteCommentSuccess, + comment1, + { type: DELETE_COMMENT_SUCCESS, comment: comment1 }, +)); + +test('should return the correct type for deleteCommentFailure', actionTest( + deleteCommentFailure, + error, + { type: DELETE_COMMENT_FAILURE, error }, +)); diff --git a/client/modules/Comment/__tests__/components/CommentFormWidget.spec.js b/client/modules/Comment/__tests__/components/CommentFormWidget.spec.js new file mode 100644 index 000000000..ab8e49386 --- /dev/null +++ b/client/modules/Comment/__tests__/components/CommentFormWidget.spec.js @@ -0,0 +1,198 @@ +import React from 'react'; +import test from 'ava'; +import sinon from 'sinon'; +import { CommentFormWidget } from '../../CommentFormWidget/CommentFormWidget'; +import { + mountWithIntl, + shallowWithIntl, +} from '../../../../util/react-intl-test-helper'; + +const props = { + initialValues: { + author: '', + content: '', + }, + onClose: () => {}, + inline: false, + addComment: () => {}, + editComment: () => {}, + submitCallback: () => {}, + params: {}, +}; + +test('renders properly', t => { + const wrapper = shallowWithIntl( + + ); + + t.truthy(wrapper.hasClass('show-btn')); +}); + +test('should trigger the form appearance', t => { + const wrapper = shallowWithIntl( + + ); + + const trigger = wrapper.find('button'); + const state = wrapper.instance().state; + + t.true(!state.isFormShown); + trigger.simulate('click'); + wrapper.setProps(props); + t.truthy(wrapper.hasClass('container')); +}); + +test('should always show the form in inline mode', t => { + const wrapper = shallowWithIntl( + + ); + + const state = wrapper.instance().state; + + t.true(!state.isFormShown); + t.truthy(wrapper.hasClass('container')); +}); + +test('should populate the form with initial values', t => { + const initialValues = { + author: 'John Doe', + content: 'Jonh\'s Doe comment', + }; + + const wrapper = mountWithIntl( + + ); + wrapper.update(); + + const state = wrapper.instance().state; + + const authorInput = wrapper.find('[name="author"]').instance(); + const contentInput = wrapper.find('[name="content"]').instance(); + + t.deepEqual(state.comment, initialValues); + t.true(authorInput.value === initialValues.author); + t.true(contentInput.value === initialValues.content); +}); + +test('should trigger onClose prop when in `inline` mode', t => { + const onClose = sinon.spy(); + const wrapper = mountWithIntl( + + ); + + const closeBtn = wrapper.find('.close-btn'); + closeBtn.simulate('click'); + + t.truthy(onClose.calledOnce); +}); + +test('should reject form submission if postId is not present in URL params', t => { + const formData = { + comment: { + author: 'John Doe', + content: 'John Doe comment', + }, + reject: () => {}, + resolve: () => {}, + }; + + const spy = sinon.spy(formData, 'reject'); + + const wrapper = mountWithIntl(); + + wrapper.instance().handleFormSubmit(formData); + + t.truthy(spy.calledWith('PostId should be specified.')); +}); + +test('should trigger callback function after successful form submission', t => { + const submitCallback = sinon.spy(); + const formData = { + comment: { + author: 'John Doe', + content: 'John Doe comment', + }, + reject: () => {}, + resolve: () => {}, + }; + + const wrapper = mountWithIntl( + , + ); + + wrapper.instance().handleFormSubmit(formData); + + t.truthy(submitCallback.calledOnce); +}); + +test('should trigger editComment prop when initialValues was suplied', t => { + const addComment = sinon.spy(); + const editComment = sinon.spy(); + + const initialValues = { + author: 'John Doe', + content: 'Jonh\'s Doe initial comment', + }; + + const formData = { + comment: { + author: 'Jane Doe', + content: 'John Doe latest comment', + }, + reject: () => {}, + resolve: () => {}, + }; + + const wrapper = mountWithIntl( + , + ); + + wrapper.instance().handleFormSubmit(formData); + + t.truthy(addComment.notCalled); + t.truthy(editComment.calledWith({ ...formData, postId: 'some-fake-cuid' })); +}); + +test('should trigger addComment prop when initialValues wasn\'t suplied', t => { + const addComment = sinon.spy(); + const editComment = sinon.spy(); + + const initialValues = null; + + const formData = { + comment: { + author: 'Jane Doe', + content: 'John Doe latest comment', + }, + reject: () => {}, + resolve: () => {}, + }; + + const wrapper = mountWithIntl( + , + ); + + wrapper.instance().handleFormSubmit(formData); + + t.truthy(editComment.notCalled); + t.truthy(addComment.calledWith({ ...formData, postId: 'some-fake-cuid' })); +}); diff --git a/client/modules/Comment/components/CommentForm/CommentForm.css b/client/modules/Comment/components/CommentForm/CommentForm.css new file mode 100644 index 000000000..1e019a14b --- /dev/null +++ b/client/modules/Comment/components/CommentForm/CommentForm.css @@ -0,0 +1,156 @@ +.form-wrapper { + max-width: 640px; + margin: 0 auto; + width: 100%; + position: relative; + border-radius: 6px; + border: 1px solid #E8E8E8; + background: #d5d7ee; + box-shadow: 2px 5px 5px rgba(0, 0, 0, 0.15); +} + +.form { + display: flex; + flex-flow: column; + align-items: stretch; + justify-content: flex-start; + padding: 25px; +} + +.input-container { + position: relative; + display: flex; + flex-flow: column; + margin-bottom: 15px; + padding: 30px 0; +} + +.label { + margin-bottom: 5px; + margin-left: 5px; + font-size: 16px; + font-family: 'Lato', sans-serif; + font-weight: 700; +} + +.input { + font-size: 18px; + color: #323232; + background: #fff; + transition: box-shadow 0.25s ease; + line-height: 40px; + padding: 12px 20px; + resize: none; + max-height: 160px; + font-family: 'Lato', sans-serif; +} + +.input:focus { + outline: none; + box-shadow: 1px 2px 3px rgba(0, 0, 0, 0.05); +} + +.input::placeholder { + color: #9b9b9b; +} + +.error { + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + color: red; + margin-top: 10px; + margin-left: 5px; + width: 100%; + text-align: center; + font-family: 'Lato', sans-serif; + font-weight: 700; +} + +.close-btn { + display: block; + position: absolute; + width: 15px; + height: 15px; + top: 15px; + right: 15px; + cursor: pointer; +} + +.close-btn::before, +.close-btn::after { + content: ''; + display: block; + position: absolute; + background: #000; + width: 100%; + height: 2px; + transition: transform 0.4s ease-in-out; + transform-origin: center center; +} + +.close-btn::before { transform: rotate(45deg); } + +.close-btn::after { transform: rotate(-45deg); } + +.close-btn:hover::before { transform: rotate(-45deg) } + +.close-btn:hover::after { transform: rotate(45deg) } + +.submit { + display: flex; + justify-content: center; + align-items: center; + margin: 10px auto 0; + background: #41c3fa; + border: 1px solid #fafafa; + outline: none; + border-radius: 12px; + max-width: 240px; + width: 100%; + height: 40px; + font-size: 18px; + color: #fff; + box-shadow: 1px 2px 2px rgba(0, 0, 0, 0.15), inset 1px 2px rgba(255, 255, 255, 0.3); + transition: all 0.15s ease; + cursor: pointer; +} + +.submit:hover:not(:disabled) { + background: #548bfa; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15), inset 0 0 transparent; +} + +.submit:disabled { + background: #6bdcfa; + box-shadow: none; +} + +.form-wrapper.inline { + max-width: inherit; + background: transparent; + box-shadow: none; + border: none; +} + +.form-wrapper.inline .form { + padding: 0; +} + +.form-wrapper.inline .form .input-container { + padding: 0 0 20px; + margin-bottom: 8px; +} + +.form-wrapper.inline .form .input { + line-height: 1.2; + padding: 5px 6px; +} + +.form-wrapper.inline .close-btn { + bottom: calc(100% - 5px); + right: 0; + top: auto; + left: auto; +} diff --git a/client/modules/Comment/components/CommentForm/CommentForm.js b/client/modules/Comment/components/CommentForm/CommentForm.js new file mode 100644 index 000000000..929a71cfc --- /dev/null +++ b/client/modules/Comment/components/CommentForm/CommentForm.js @@ -0,0 +1,120 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import { Formik } from 'formik'; + +// Import styles +import styles from './CommentForm.css'; + +// Import constants +import { CommentFormValidationSchema } from '../../constants'; + +const CommentForm = (props, { intl }) => ( +
+
+ + + {props.errors.author &&
{props.errors.author}
} +
+
+ +