diff --git a/news/4074.feature b/news/4074.feature new file mode 100644 index 0000000000..97c90d3e17 --- /dev/null +++ b/news/4074.feature @@ -0,0 +1 @@ +Refactor Comment -@Tishasoumya-02 diff --git a/src/components/theme/Comments/Comment.stories.jsx b/src/components/theme/Comments/Comment.stories.jsx new file mode 100644 index 0000000000..b2d327c08f --- /dev/null +++ b/src/components/theme/Comments/Comment.stories.jsx @@ -0,0 +1,84 @@ +import { injectIntl } from 'react-intl'; +import React from 'react'; +import CommentsComponent from './Comments'; +import { RealStoreWrapper as Wrapper } from '@plone/volto/storybook'; + +const IntlCommentsComponent = injectIntl(CommentsComponent); + +function StoryComponent(args) { + return ( + +
+ + + ); +} + +export const CommentsModal = StoryComponent.bind({}); +CommentsModal.args = { + author_name: 'admin', + creation_date: '2017-11-06T19:36:01', + text: { data: 'Plone 6' }, +}; +export default { + title: 'Public components/Comments/Comments Modal', + component: CommentsComponent, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + creation_date: { + control: 'date', + description: 'Date comment was created', + }, + author_name: { + control: 'text', + description: 'Comment author name', + }, + text: { + data: { + control: 'date', + }, + description: 'Comment text', + }, + }, +}; diff --git a/src/components/theme/Comments/Comments.jsx b/src/components/theme/Comments/Comments.jsx index 988e36226b..6e284fa42f 100644 --- a/src/components/theme/Comments/Comments.jsx +++ b/src/components/theme/Comments/Comments.jsx @@ -1,8 +1,12 @@ -/** - * Comments components. - * @module components/theme/Comments/Comments - */ +import { useEffect, useState, useMemo, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { Portal } from 'react-portal'; +import { useDispatch, useSelector, shallowEqual } from 'react-redux'; +import { compose } from 'redux'; +import { Button, Comment, Container, Icon } from 'semantic-ui-react'; +import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable'; import { addComment, deleteComment, @@ -10,16 +14,12 @@ import { listMoreComments, } from '@plone/volto/actions'; import { Avatar, CommentEditModal, Form } from '@plone/volto/components'; -import { flattenToAppURL, getBaseUrl, getColor } from '@plone/volto/helpers'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; -import { Portal } from 'react-portal'; -import { connect } from 'react-redux'; -import { compose } from 'redux'; -import { Button, Comment, Container, Icon } from 'semantic-ui-react'; -import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable'; -// import { Button, Grid, Segment, Container } from 'semantic-ui-react'; +import { + flattenToAppURL, + getBaseUrl, + getColor, + usePrevious, +} from '@plone/volto/helpers'; const messages = defineMessages({ comment: { @@ -65,10 +65,7 @@ const messages = defineMessages({ defaultMessage: 'Load more...', }, }); -/** - * Schema for the Form components to show an input field with it's label - * @param {Object} intl - */ + const makeFormSchema = (intl) => ({ fieldsets: [ { @@ -87,193 +84,114 @@ const makeFormSchema = (intl) => ({ required: ['comment1'], }); -/** - * Comments container class. - * @class Comments - * @extends Component - */ -class Comments extends Component { - /** - * Property types. - * @property {Object} propTypes Property types. - * @static - */ - static propTypes = { - addComment: PropTypes.func.isRequired, - deleteComment: PropTypes.func.isRequired, - listComments: PropTypes.func.isRequired, - listMoreComments: PropTypes.func.isRequired, - pathname: PropTypes.string.isRequired, - items: PropTypes.arrayOf( - PropTypes.shape({ - author_name: PropTypes.string, - creation_date: PropTypes.string, - text: PropTypes.shape({ - data: PropTypes.string, - 'mime-type': PropTypes.string, - }), - is_deletable: PropTypes.bool, - is_editable: PropTypes.bool, - }), - ).isRequired, - addRequest: PropTypes.shape({ - loading: PropTypes.bool, - loaded: PropTypes.bool, - }).isRequired, - deleteRequest: PropTypes.shape({ - loading: PropTypes.bool, - loaded: PropTypes.bool, - }).isRequired, - }; +const useComments = () => { + const items = useSelector((state) => state.comments.items, shallowEqual); + const next = useSelector((state) => state.comments.next, shallowEqual); + const items_total = useSelector( + (state) => state.comments.items_total, + shallowEqual, + ); + const permissions = useSelector( + (state) => state.comments.permissions || {}, + shallowEqual, + ); + const addRequest = useSelector((state) => state.comments.add, shallowEqual); + const deleteRequest = useSelector( + (state) => state.comments.delete, + shallowEqual, + ); - /** - * Constructor - * @method constructor - * @param {Object} props Component properties - * @constructs Comments - */ - constructor(props) { - super(props); - this.onSubmit = this.onSubmit.bind(this); - this.onDelete = this.onDelete.bind(this); - this.onEdit = this.onEdit.bind(this); - this.onEditOk = this.onEditOk.bind(this); - this.onEditCancel = this.onEditCancel.bind(this); - this.setReplyTo = this.setReplyTo.bind(this); - this.loadMoreComments = this.loadMoreComments.bind(this); - this.state = { - showEdit: false, - editId: null, - editText: null, - replyTo: null, - collapsedComments: {}, - }; - } + return { items, next, items_total, permissions, addRequest, deleteRequest }; +}; - componentDidMount() { - this.props.listComments(getBaseUrl(this.props.pathname)); - } +const Comments = (props) => { + const intl = useIntl(); + const dispatch = useDispatch(); + const { pathname } = props; + const [showEdit, setshowEdit] = useState(false); + const [editId, seteditId] = useState(null); + const [editText, seteditText] = useState(null); + const [replyTo, setreplyTo] = useState(null); + const [collapsedComments, setcollapsedComments] = useState({}); + const { + items, + next, + items_total, + permissions, + addRequest, + deleteRequest, + } = useComments(); - /** - * Component will receive props - * @method componentWillReceiveProps - * @param {Object} nextProps Next properties - * @returns {undefined} - */ - UNSAFE_componentWillReceiveProps(nextProps) { + const prevpathname = usePrevious(pathname); + + const prevaddRequestLoading = usePrevious(addRequest.loading); + const prevdeleteRequestLoading = usePrevious(deleteRequest.loading); + + useEffect(() => { if ( - nextProps.pathname !== this.props.pathname || - (this.props.addRequest.loading && nextProps.addRequest.loaded) || - (this.props.deleteRequest.loading && nextProps.deleteRequest.loaded) + pathname !== prevpathname || + (prevaddRequestLoading && addRequest.loaded) || + (prevdeleteRequestLoading && deleteRequest.loaded) ) { - this.props.listComments(getBaseUrl(nextProps.pathname)); + dispatch(listComments(getBaseUrl(pathname))); } - } + }, [ + dispatch, + pathname, + prevpathname, + prevaddRequestLoading, + addRequest.loaded, + prevdeleteRequestLoading, + deleteRequest.loaded, + ]); - /** - * Submit handler - * @method onSubmit - * @param {Object} formData Form data. - * @returns {undefined} - */ - onSubmit(formData) { - this.props.addComment( - getBaseUrl(this.props.pathname), - formData.comment, - this.state.replyTo, - ); - this.setState({ replyTo: null }); - } - - /** - * The id of the comment that will receive a reply - * @param {string} commentId - */ - setReplyTo(commentId) { - this.setState({ replyTo: commentId }); - } + const onSubmit = (formData) => { + dispatch(addComment(getBaseUrl(pathname), formData.comment, replyTo)); + setreplyTo(null); + }; - /** - * Calls the action listMoreComments passing the received url for next array of comments - */ - loadMoreComments() { - this.props.listMoreComments(this.props.next); - } + const setReplyTo = (commentId) => { + setreplyTo(commentId); + }; - /** - * Delete handler - * @method onDelete - * @param {Object} event Event object. - * @param {string} value Delete value. - * @returns {undefined} - */ - onDelete(value) { - this.props.deleteComment(value); - } + const loadMoreComments = () => { + dispatch(listMoreComments(next)); + }; - /** - * Will hide all replies to the specific comment - * including replies to any of the replies - * @param {string} commentId - */ - hideReply(commentId) { - this.setState((prevState) => { - const hasComment = prevState.collapsedComments[commentId]; - const { collapsedComments } = prevState; + const onDelete = (value) => { + dispatch(deleteComment(value)); + }; + const prevcollapsedComments = usePrevious(collapsedComments); - return { - collapsedComments: { - ...collapsedComments, - [commentId]: !hasComment, - }, - }; - }); - } + const hideReply = (commentId) => { + const hasComment = prevcollapsedComments[commentId]; + setcollapsedComments((prevState) => ({ + ...prevState, + [commentId]: !hasComment, + })); + }; - /** - * Edit handler - * @method onEdit - * @param {Object} event Event object. - * @param {string} value Delete value. - * @returns {undefined} - */ - onEdit(value) { - this.setState({ - showEdit: true, - editId: value.id, - editText: value.text, - }); - } + const onEdit = useCallback((value) => { + setshowEdit(true); + seteditText(value.text); + seteditId(value.id); + }, []); - /** - * On edit ok - * @method onEditOk - * @returns {undefined} - */ - onEditOk() { - this.setState({ - showEdit: false, - editId: null, - editText: null, - }); - this.props.listComments(getBaseUrl(this.props.pathname)); - } + const onEditOk = () => { + setshowEdit(false); + seteditId(null); + seteditText(null); + dispatch(listComments(getBaseUrl(pathname))); + }; - /** - * On edit cancel - * @method onEditCancel - * @returns {undefined} - */ - onEditCancel(ev) { - this.setState({ - showEdit: false, - editId: null, - editText: null, - replyTo: null, - }); - } + const onEditCancel = useCallback(() => { + setshowEdit(false); + seteditId(null); + seteditText(null); + setreplyTo(null); + }, []); - addRepliesAsChildrenToComments(items) { + const addRepliesAsChildrenToComments = (items) => { let initialValue = {}; const allCommentsWithCildren = items.reduce((accumulator, item) => { return { @@ -288,205 +206,182 @@ class Comments extends Component { } }); return allCommentsWithCildren; - } + }; - /** - * Render method. - * @method render - * @returns {string} Markup for the component. - */ - render() { - const { items, permissions } = this.props; - const moment = this.props.moment.default; - const { collapsedComments } = this.state; - // object with comment ids, to easily verify if any comment has children - const allCommentsWithCildren = this.addRepliesAsChildrenToComments(items); - // all comments that are not a reply will be shown in the first iteration - const allPrimaryComments = items.filter((comment) => !comment.in_reply_to); + const moment = props.moment.default; - // recursively makes comments with their replies nested - // each iteration will show replies to the specific comment using allCommentsWithCildren - const commentElement = (comment) => ( - - - - {comment.author_name} - - - {' '} - - {moment(comment.creation_date).fromNow()} - - - - + const allCommentsWithCildren = useMemo( + () => addRepliesAsChildrenToComments(items), + [items], + ); + // all comments that are not a reply will be shown in the first iteration + const allPrimaryComments = items.filter((comment) => !comment.in_reply_to); + + // recursively makes comments with their replies nested + // each iteration will show replies to the specific comment using allCommentsWithCildren + const commentElement = (comment) => ( + + + + {comment.author_name} + + {' '} - {comment.text['mime-type'] === 'text/html' ? ( -
- ) : ( - comment.text.data - )} - - - {comment.can_reply && ( - this.setReplyTo(comment.comment_id)} - > - - - )} - {comment.is_editable && ( - - this.onEdit({ - id: flattenToAppURL(comment['@id']), - text: comment.text.data, - }) - } - aria-label={this.props.intl.formatMessage(messages.edit)} - value={{ + + {moment(comment.creation_date).fromNow()} + + + + + {' '} + {comment.text['mime-type'] === 'text/html' ? ( +
+ ) : ( + comment.text.data + )} + + + {comment.can_reply && ( + setReplyTo(comment.comment_id)} + > + + + )} + {comment.is_editable && ( + + onEdit({ id: flattenToAppURL(comment['@id']), text: comment.text.data, - }} - > - - - )} - {comment.is_deletable && ( - this.onDelete(flattenToAppURL(comment['@id']))} - color="red" - > - - - - )} + }) + } + aria-label={intl.formatMessage(messages.edit)} + value={{ + id: flattenToAppURL(comment['@id']), + text: comment.text.data, + }} + > + + + )} + {comment.is_deletable && ( this.hideReply(comment.comment_id)} + aria-label={intl.formatMessage(messages.delete)} + onClick={() => onDelete(flattenToAppURL(comment['@id']))} + color="red" > - {allCommentsWithCildren[comment.comment_id].children.length > - 0 ? ( - this.state.collapsedComments[comment.comment_id] ? ( - <> - - - - ) : ( - <> - - - - ) - ) : null} + + - -
- + )} + hideReply(comment.comment_id)}> + {allCommentsWithCildren[comment.comment_id].children.length > 0 ? ( + collapsedComments[comment.comment_id] ? ( + <> + + + + ) : ( + <> + + + + ) + ) : null} + + +
+ + + {allCommentsWithCildren[comment.comment_id].children.length > 0 + ? allCommentsWithCildren[comment.comment_id].children.map( + (child, index) => ( + + {commentElement(child)} + + ), + ) + : null} + + ); - {allCommentsWithCildren[comment.comment_id].children.length > 0 - ? allCommentsWithCildren[comment.comment_id].children.map( - (child, index) => ( - - {commentElement(child)} - - ), - ) - : null} - - ); + if (!permissions.view_comments) return ''; - if (!permissions.view_comments) return ''; + return ( + + + {permissions.can_reply && ( +
+
+
+ )} + {/* all comments */} + + {allPrimaryComments.map((item) => commentElement(item))} + - return ( - - - {permissions.can_reply && ( -
- -
- )} - {/* all comments */} - - {allPrimaryComments.map((item) => commentElement(item))} - + {/* load more button */} + {items_total > items.length && ( + + )} - {/* load more button */} - {this.props.items_total > this.props.items.length && ( - - )} + {replyTo && ( + + + + )} +
+ ); +}; - {this.state.replyTo && ( - - - - )} -
- ); - } -} +Comments.propTypes = { + pathname: PropTypes.string.isRequired, +}; -export default compose( - injectIntl, - injectLazyLibs(['moment']), - connect( - (state) => ({ - items: state.comments.items, - next: state.comments.next, - items_total: state.comments.items_total, - permissions: state.comments.permissions || {}, - addRequest: state.comments.add, - deleteRequest: state.comments.delete, - }), - { addComment, deleteComment, listComments, listMoreComments }, - ), -)(Comments); +export default compose(injectLazyLibs(['moment']))(Comments);