From 819598617bf2754a7326b210af4135adf968f036 Mon Sep 17 00:00:00 2001 From: David Muhanguzi Date: Tue, 20 Nov 2018 10:01:43 +0300 Subject: [PATCH] feature(comments): update comments - update comments on articles [Delivers #161959254] --- package.json | 5 +- src/actions/actionCreators.js | 16 +++ src/actions/articleActions.js | 30 +++- src/actions/types.js | 3 + src/components/Articles/SingleArticle.js | 2 +- src/components/comments/CommentList.js | 6 +- src/components/comments/Comments.js | 2 +- .../comments/RetrieveCommentForm.js | 5 +- src/components/comments/UpdateComment.js | 129 ++++++++++++++++++ src/components/landingPage/Navbar.js | 10 +- src/components/routes/index.js | 2 + src/reducers/articlesReducer.js | 22 +++ src/tests/actions/articleActions.test.js | 61 +++++++++ src/tests/components/UpdateComment.test.js | 92 +++++++++++++ src/tests/reducers/articleReducer.test.js | 43 +++++- 15 files changed, 414 insertions(+), 14 deletions(-) create mode 100644 src/components/comments/UpdateComment.js create mode 100644 src/tests/components/UpdateComment.test.js diff --git a/package.json b/package.json index f9f8625..2b74a81 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "moment": "^2.22.2", "node-sass": "^4.9.4", "prop-types": "^15.6.2", - "react": "^16.6.0", - "react-dom": "^16.6.0", + "react": "^16.6.3", + "react-dom": "^16.6.3", "react-facebook-login": "^4.1.1", "react-google-login": "^3.2.1", "react-loader": "^2.4.5", @@ -26,6 +26,7 @@ "react-router-dom": "^4.3.1", "react-scripts": "2.0.5", "react-toastify": "^4.4.0", + "reactstrap": "^6.5.0", "redux": "^4.0.1", "redux-mock-store": "^1.5.3", "redux-thunk": "^2.3.0" diff --git a/src/actions/actionCreators.js b/src/actions/actionCreators.js index 56d9139..0a47fe6 100644 --- a/src/actions/actionCreators.js +++ b/src/actions/actionCreators.js @@ -15,6 +15,9 @@ import { LIKE_DISLIKE_SUCCESS, LIKE_DISLIKE_ERROR, DELETE_ARTICLE_SUCCESS, + UPDATE_COMMENT_INITIATED, + UPDATE_COMMENT_SUCCESS, + UPDATE_COMMENT_ERROR, } from './types'; export const socialLoginInitiated = () => ({ @@ -87,3 +90,16 @@ export const deleteArticleSuccess = payload => ({ type: DELETE_ARTICLE_SUCCESS, payload, }); + +export const updateCommentInitiated = payload => ({ + type: UPDATE_COMMENT_INITIATED, + payload, +}); +export const updateCommentSuccess = payload => ({ + type: UPDATE_COMMENT_SUCCESS, + payload, +}); +export const updateCommentError = payload => ({ + type: UPDATE_COMMENT_ERROR, + payload, +}); diff --git a/src/actions/articleActions.js b/src/actions/articleActions.js index 54c328b..420864a 100644 --- a/src/actions/articleActions.js +++ b/src/actions/articleActions.js @@ -15,6 +15,9 @@ import { likeDislikeSuccess, likeDislikeError, deleteArticleSuccess, + updateCommentInitiated, + updateCommentSuccess, + updateCommentError, } from './actionCreators'; export const postArticle = postData => dispatch => { @@ -57,7 +60,7 @@ export const fetchComments = article => dispatch => { .catch(() => { localStorage.removeItem('auth_token'); dispatch(logoutUser(false)); - toast.error('Please login to view comments', { autoClose: false, hideProgressBar: true }); + toast.error('Please login', { autoClose: false, hideProgressBar: true }); }); }; @@ -125,3 +128,28 @@ export const deleteArticle = slug => dispatch => { ); }); }; + +export const editComment = (payload, slug, comment) => dispatch => { + toast.dismiss(); + dispatch(updateCommentInitiated(true)); + axiosInstance + .put(`/api/articles/${slug}/comments/${comment}/`, payload) + .then(() => { + const success_message = 'Comment updated successfully'; + dispatch(updateCommentSuccess(true)); + toast.success( + success_message, + { autoClose: 3500, hideProgressBar: true }, + ); + }) + .catch((error) => { + let errorMessage = ''; + if (error.response.status === 403) { + errorMessage = 'Re-login and try again'; + } else { + errorMessage = 'This comment is non existent'; + } + dispatch(updateCommentError(errorMessage)); + toast.error(errorMessage, { autoClose: false, hideProgressBar: true }); + }); +}; diff --git a/src/actions/types.js b/src/actions/types.js index fbec0a4..c4e446f 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -21,3 +21,6 @@ export const GET_ALL_ARTICLES_INITIATED = 'GET_ALL_ARTICLES_INITIATED'; export const LIKE_DISLIKE_SUCCESS = 'LIKE_DISLIKE_SUCCESS'; export const LIKE_DISLIKE_ERROR = 'LIKE_DISLIKE_ERROR'; export const DELETE_ARTICLE_SUCCESS = 'DELETE_ARTICLE_SUCCESS'; +export const UPDATE_COMMENT_INITIATED = 'UPDATE_COMMENT_INITIATED'; +export const UPDATE_COMMENT_SUCCESS = 'UPDATE_COMMENT_SUCCESS'; +export const UPDATE_COMMENT_ERROR = 'UPDATE_COMMENT_ERROR'; diff --git a/src/components/Articles/SingleArticle.js b/src/components/Articles/SingleArticle.js index 0831620..76300f6 100644 --- a/src/components/Articles/SingleArticle.js +++ b/src/components/Articles/SingleArticle.js @@ -52,7 +52,7 @@ const SingleArticle = ({ article }) => { - + diff --git a/src/components/comments/CommentList.js b/src/components/comments/CommentList.js index b918a05..2c053de 100644 --- a/src/components/comments/CommentList.js +++ b/src/components/comments/CommentList.js @@ -2,20 +2,22 @@ import React from 'react'; import PropTypes from 'prop-types'; import RetrieveCommentForm from './RetrieveCommentForm'; -export const CommentList = ({ comments }) => ( +export const CommentList = ({ comments, article }) => (
{comments.map(comment => ( - + ))}
); CommentList.propTypes = { comments: PropTypes.array, + article: PropTypes.string, }; CommentList.defaultProps = { comments: [], + article: '', }; export default CommentList; diff --git a/src/components/comments/Comments.js b/src/components/comments/Comments.js index b3bf8af..c3624d2 100644 --- a/src/components/comments/Comments.js +++ b/src/components/comments/Comments.js @@ -80,7 +80,7 @@ export class Comments extends Component { /> {Object.keys(commentsPayload).length > 0 - && + && } diff --git a/src/components/comments/RetrieveCommentForm.js b/src/components/comments/RetrieveCommentForm.js index 37f7b97..888f84c 100644 --- a/src/components/comments/RetrieveCommentForm.js +++ b/src/components/comments/RetrieveCommentForm.js @@ -2,8 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import Moment from 'react-moment'; import user from '../../assets/images/user.png'; +import UpdateComment from './UpdateComment'; -export const RetrieveCommentForm = ({ comment }) => ( +export const RetrieveCommentForm = ({ comment, article }) => (
@@ -34,6 +35,7 @@ export const RetrieveCommentForm = ({ comment }) => (
+ @@ -58,6 +60,7 @@ export const RetrieveCommentForm = ({ comment }) => ( RetrieveCommentForm.propTypes = { comment: PropTypes.object.isRequired, + article: PropTypes.string.isRequired, }; export default RetrieveCommentForm; diff --git a/src/components/comments/UpdateComment.js b/src/components/comments/UpdateComment.js new file mode 100644 index 0000000..0822fdc --- /dev/null +++ b/src/components/comments/UpdateComment.js @@ -0,0 +1,129 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import ReactQuill from 'react-quill'; +import PropTypes from 'prop-types'; +import Loader from 'react-loader'; +import { + Modal, + ModalHeader, + ModalBody, + ModalFooter, +} from 'reactstrap'; +import { editComment } from '../../actions/articleActions'; + +export class UpdateComment extends Component { + constructor(props) { + super(props); + this.state = { + body: '', + modal: false, + }; + } + + componentDidMount = () => { + const { comment } = this.props; + this.setState({ body: comment.body }); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.updateCommentSuccess === true) { + window.location.reload(); + } + if (nextProps.isLoggedIn === false) { + const { history } = this.props; + history.push('/login'); + } + } + + toggle = () => { + const { modal } = this.state; + this.setState({ + modal: !modal, + }); + } + + handleChange = value => { + this.setState({ body: value }); + } + + handleSubmit = event => { + event.preventDefault(); + const { editComment, slug, comment: { id } } = this.props; + const { body, modal } = this.state; + const payload = { + comment: { + body, + }, + }; + editComment(payload, slug, id); + this.setState({ + modal: !modal, + }); + } + + render() { + const { + body, + modal, + } = this.state; + const { + loading, + className, + buttonLabel, + } = this.props; + return ( + + + {buttonLabel} + + Edit comment + + + + + + + + + + + ); + } +} + +const mapStateToProps = (state) => ({ + isLoggedIn: state.user.isLoggedIn, + loading: state.user.loading, + updateCommentSuccess: state.article.updateCommentSuccess, +}); + +const matchDispatchToProps = (dispatch) => bindActionCreators({ + editComment, +}, dispatch); + +UpdateComment.propTypes = { + updateCommentSuccess: PropTypes.bool, + comment: PropTypes.object.isRequired, + slug: PropTypes.string.isRequired, + editComment: PropTypes.func.isRequired, + className: PropTypes.string.isRequired, + buttonLabel: PropTypes.string.isRequired, + history: PropTypes.object.isRequired, + loading: PropTypes.bool.isRequired, + isLoggedIn: PropTypes.object.isRequired, +}; + +UpdateComment.defaultProps = { + updateCommentSuccess: false, +}; + +export default connect(mapStateToProps, matchDispatchToProps)(UpdateComment); diff --git a/src/components/landingPage/Navbar.js b/src/components/landingPage/Navbar.js index b8b7c4b..dac0e36 100644 --- a/src/components/landingPage/Navbar.js +++ b/src/components/landingPage/Navbar.js @@ -59,10 +59,10 @@ export class Navbar extends Component {
- New Article - Profile + New Article + Profile
- Logout + Logout
@@ -71,12 +71,12 @@ export class Navbar extends Component { : (
  • - + Signup
  • - Login + Login
  • ) diff --git a/src/components/routes/index.js b/src/components/routes/index.js index 7b36bfe..f1d2bd0 100644 --- a/src/components/routes/index.js +++ b/src/components/routes/index.js @@ -8,6 +8,7 @@ import Dashboard from '../dashboard/Dashboard'; import Profile from '../profiles/Profile'; import NewArticle from '../Articles/NewArticle'; import Comments from '../comments/Comments'; +import UpdateComment from '../comments/UpdateComment'; import ViewArticle from '../Articles/ViewArticle'; const Routes = () => ( @@ -19,6 +20,7 @@ const Routes = () => ( + diff --git a/src/reducers/articlesReducer.js b/src/reducers/articlesReducer.js index 0a0f75a..b423a14 100644 --- a/src/reducers/articlesReducer.js +++ b/src/reducers/articlesReducer.js @@ -12,6 +12,9 @@ import { LIKE_DISLIKE_ERROR, LIKE_DISLIKE_SUCCESS, DELETE_ARTICLE_SUCCESS, + UPDATE_COMMENT_INITIATED, + UPDATE_COMMENT_SUCCESS, + UPDATE_COMMENT_ERROR, } from '../actions/types'; const initialState = { @@ -26,6 +29,8 @@ const initialState = { likeDislikeSuccess: false, likeDislikeError: {}, confirmDelete: false, + updateCommentError: {}, + updateCommentSuccess: false, }; export const articlesReducer = (state = initialState, action) => { @@ -102,6 +107,23 @@ export const articlesReducer = (state = initialState, action) => { loading: false, confirmDelete: action.payload, }; + case UPDATE_COMMENT_INITIATED: + return { + ...state, + loading: action.payload, + }; + case UPDATE_COMMENT_SUCCESS: + return { + ...state, + updateCommentSuccess: action.payload, + loading: false, + }; + case UPDATE_COMMENT_ERROR: + return { + ...state, + updateCommentError: action.payload, + loading: false, + }; default: return state; } diff --git a/src/tests/actions/articleActions.test.js b/src/tests/actions/articleActions.test.js index 87da3c8..dc4bc66 100644 --- a/src/tests/actions/articleActions.test.js +++ b/src/tests/actions/articleActions.test.js @@ -11,6 +11,7 @@ import { fetchUserArticles, likeDislike, deleteArticle, + editComment, } from '../../actions/articleActions'; import { CREATE_ARTICLE_SUCCESS, @@ -27,6 +28,9 @@ import { LIKE_DISLIKE_SUCCESS, LIKE_DISLIKE_ERROR, DELETE_ARTICLE_SUCCESS, + UPDATE_COMMENT_INITIATED, + UPDATE_COMMENT_SUCCESS, + UPDATE_COMMENT_ERROR, } from '../../actions/types'; let store; @@ -178,6 +182,63 @@ describe('articleActions', () => { ); }); + it('should update a comment', async () => { + const comment = 1; + const slug = 'react-redux'; + const response_data = { + comment: { + body: 'react-redux', + }, + }; + mock.onPut('/api/articles/react-redux/comments/1/').reply(200, response_data); + editComment(response_data, slug, comment)(store.dispatch); + await flushAllPromises(); + expect(store.getActions()).toEqual( + [ + { type: UPDATE_COMMENT_INITIATED, payload: true }, + { type: UPDATE_COMMENT_SUCCESS, payload: true }, + ], + ); + }); + + it('should not update a comment when a user is not logged in', async () => { + const comment = 1; + const slug = 'react-redux'; + const response_data = { + comment: { + body: 'react-redux', + }, + }; + mock.onPut('/api/articles/react-redux/comments/1/').reply(403, response_data); + editComment(response_data, slug, comment)(store.dispatch); + await flushAllPromises(); + expect(store.getActions()).toEqual( + [ + { type: UPDATE_COMMENT_INITIATED, payload: true }, + { type: UPDATE_COMMENT_ERROR, payload: 'Re-login and try again' }, + ], + ); + }); + + it('should not update a comment when a non existent comment is selected', async () => { + const comment = 1; + const slug = 'react-redux'; + const response_data = { + comment: { + body: 'react-redux', + }, + }; + mock.onPut('/api/articles/react-redux/comments/1/').reply(404, response_data); + editComment(response_data, slug, comment)(store.dispatch); + await flushAllPromises(); + expect(store.getActions()).toEqual( + [ + { type: UPDATE_COMMENT_INITIATED, payload: true }, + { type: UPDATE_COMMENT_ERROR, payload: 'This comment is non existent' }, + ], + ); + }); + it('should return user artciles', async () => { const response_data = { article: { diff --git a/src/tests/components/UpdateComment.test.js b/src/tests/components/UpdateComment.test.js new file mode 100644 index 0000000..3c98bbc --- /dev/null +++ b/src/tests/components/UpdateComment.test.js @@ -0,0 +1,92 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import configureMockStore from 'redux-mock-store'; +import { UpdateComment } from '../../components/comments/UpdateComment'; + +describe('UpdateComment component', () => { + let wrapper; + const mockStore = configureMockStore(); + const props = { + history: { push: jest.fn() }, + comment: { + id: 1, + body: 'the body', + }, + }; + const nextProps = { + updateCommentSuccess: true, + }; + + Object.defineProperty(window.location, 'reload', { + configurable: true, + }); + + window.location.reload = jest.fn(); + + const getEvent = (name = '', value = '') => ({ + preventDefault: jest.fn(), + target: { + name, + value, + }, + }); + + const editComment = jest.fn(); + const handleSubmit = jest.fn(); + const handleChange = jest.fn(); + const toggle = jest.fn(); + + beforeEach(() => { + mockStore({}); + wrapper = shallow(); + }); + + it('should render correctly', () => { + expect(wrapper).toMatchSnapshot(); + }); + + it('should set component state on rendering', () => { + expect(wrapper.state().body).toEqual('the body'); + }); + + it('should not reload if updateCommentSuccess is false', () => { + wrapper.setProps({ updateCommentSuccess: false }); + expect(window.location.reload).toBeCalledTimes(0); + }); + + it('should not redirect if isLoggedIn is false', () => { + wrapper.setProps({ ...nextProps }); + expect(props.history.push).toBeCalledTimes(0); + }); + + it('should redirect to login page if user is not authenticated', () => { + wrapper.setProps({ isLoggedIn: false }); + expect(props.history.push).toBeCalledWith('/login'); + }); + + it('should reload if updateCommentSuccess is true', () => { + wrapper.setProps({ ...nextProps }); + expect(window.location.reload).toBeCalled(); + }); + + it('should call editComment when handleSubmit is called', () => { + wrapper.instance().handleSubmit(getEvent()); + expect(editComment).toBeCalled(); + }); + + it('should set state when toggle is called is called', () => { + wrapper.instance().toggle(); + expect(wrapper.state().modal).toEqual(true); + }); + + it('should set state when handleChange is called is called', () => { + wrapper.instance().handleChange('OurValue'); + expect(wrapper.state().body).toEqual('OurValue'); + }); +}); diff --git a/src/tests/reducers/articleReducer.test.js b/src/tests/reducers/articleReducer.test.js index 915da20..1b102e7 100644 --- a/src/tests/reducers/articleReducer.test.js +++ b/src/tests/reducers/articleReducer.test.js @@ -12,6 +12,9 @@ import { LIKE_DISLIKE_SUCCESS, LIKE_DISLIKE_ERROR, DELETE_ARTICLE_SUCCESS, + UPDATE_COMMENT_INITIATED, + UPDATE_COMMENT_SUCCESS, + UPDATE_COMMENT_ERROR, } from '../../actions/types'; describe('articlesReducer', () => { @@ -30,6 +33,8 @@ describe('articlesReducer', () => { likeDislikeSuccess: false, likeDislikeError: {}, confirmDelete: false, + updateCommentError: {}, + updateCommentSuccess: false, }; }); @@ -46,7 +51,6 @@ describe('articlesReducer', () => { expect(currentState).toEqual({ ...initialState, articlesPayload: action.payload, - }); }); @@ -172,6 +176,43 @@ describe('articlesReducer', () => { }); }); + it('should set loading to true when UPDATE_COMMENT_INITIATED is dispatched', () => { + const action = { + type: UPDATE_COMMENT_INITIATED, + payload: true, + }; + const currentState = articlesReducer(initialState, action); + expect(currentState).toEqual({ + ...initialState, + loading: true, + }); + }); + + it('should set updateCommentSuccess to true when UPDATE_COMMENT_SUCCESS is dispatched', () => { + const action = { + type: UPDATE_COMMENT_SUCCESS, + payload: true, + }; + const currentState = articlesReducer(initialState, action); + expect(currentState).toEqual({ + ...initialState, + updateCommentSuccess: true, + }); + }); + + it('should pass a payload to updateCommentError when UPDATE_COMMENT_ERROR is dispatched', () => { + const errorMessage = 'Re-login and try again'; + const action = { + type: UPDATE_COMMENT_ERROR, + payload: errorMessage, + }; + const currentState = articlesReducer(initialState, action); + expect(currentState).toEqual({ + ...initialState, + updateCommentError: errorMessage, + }); + }); + it('should set DELETE_ARTICLE_SUCCESS to true when article has been deleted', () => { const action = { type: DELETE_ARTICLE_SUCCESS,