Răsfoiți Sursa

Merge pull request #89 from weseek/feat/remove-comment

Feat/remove comment
Yuki Takei 8 ani în urmă
părinte
comite
d350ca064c

+ 21 - 0
lib/routes/comment.js

@@ -72,5 +72,26 @@ module.exports = function(crowi, app) {
       });
       });
   };
   };
 
 
+  /**
+   * @api {post} /comments.remove Remove specified comment
+   * @apiName RemoveComment
+   * @apiGroup Comment
+   *
+   * @apiParam {String} comment_id Comment Id.
+   */
+  api.remove = function(req, res){
+    var commentId = req.body.comment_id;
+    if (!commentId) {
+      return res.json(ApiResponse.error(`'comment_id' is undefined`));
+    }
+
+    return Comment.remove({_id: commentId})
+      .then(function() {
+        return res.json(ApiResponse.success({}));
+      }).catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
+  };
+
   return actions;
   return actions;
 };
 };

+ 1 - 0
lib/routes/index.js

@@ -119,6 +119,7 @@ module.exports = function(crowi, app) {
   app.post('/_api/pages.unlink'       , loginRequired(crowi, app) , csrf, page.api.unlink); // (Avoid from API Token)
   app.post('/_api/pages.unlink'       , loginRequired(crowi, app) , csrf, page.api.unlink); // (Avoid from API Token)
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app, false) , comment.api.get);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app, false) , comment.api.get);
   app.post('/_api/comments.add'       , form.comment, accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);
   app.post('/_api/comments.add'       , form.comment, accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);
+  app.post('/_api/comments.remove'    , accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.remove);
   app.get( '/_api/bookmarks.get'      , accessTokenParser , loginRequired(crowi, app, false) , bookmark.api.get);
   app.get( '/_api/bookmarks.get'      , accessTokenParser , loginRequired(crowi, app, false) , bookmark.api.get);
   app.post('/_api/bookmarks.add'      , accessTokenParser , loginRequired(crowi, app) , csrf, bookmark.api.add);
   app.post('/_api/bookmarks.add'      , accessTokenParser , loginRequired(crowi, app) , csrf, bookmark.api.add);
   app.post('/_api/bookmarks.remove'   , accessTokenParser , loginRequired(crowi, app) , csrf, bookmark.api.remove);
   app.post('/_api/bookmarks.remove'   , accessTokenParser , loginRequired(crowi, app) , csrf, bookmark.api.remove);

+ 34 - 3
resource/css/_comment.scss

@@ -1,3 +1,20 @@
+.crowi.main-container {
+  .page-comment-delete-modal .modal-content {
+    .modal-body {
+      .comment-body {
+        background-color: #eee;
+        padding: .5em;
+        margin-top: .5em;
+        border-radius: 4px;
+
+        // scrollable
+        overflow-y: auto;
+        max-height: 13em;
+      }
+    }
+  }
+}
+
 .crowi.main-container aside.sidebar .side-content {
 .crowi.main-container aside.sidebar .side-content {
 
 
 .page-comments {
 .page-comments {
@@ -43,11 +60,11 @@
       }
       }
 
 
       .page-comment-creator {
       .page-comment-creator {
-        margin-left: 40px;
         font-weight: bold;
         font-weight: bold;
       }
       }
 
 
       .page-comment-main {
       .page-comment-main {
+        position: relative;
         margin-left: 40px;
         margin-left: 40px;
 
 
         .page-comment-meta {
         .page-comment-meta {
@@ -58,6 +75,21 @@
           padding: 8px 0;
           padding: 8px 0;
           word-wrap: break-word;
           word-wrap: break-word;
         }
         }
+        .page-comment-control {
+          position: absolute;
+          display: none;    // default hidden
+          top: 0;
+          right: 0;
+
+          a {
+            padding: 3px;
+          }
+        }
+      }
+
+      // show controls when hover
+      .page-comment-main:hover > .page-comment-control {
+        display: block;
       }
       }
     }
     }
 
 
@@ -75,6 +107,7 @@
           //background: lighten($crowiHeaderBackground, 65%);
           //background: lighten($crowiHeaderBackground, 65%);
         }
         }
       }
       }
+
     }
     }
 
 
     .page-comment.page-comment-old {
     .page-comment.page-comment-old {
@@ -87,6 +120,4 @@
   }
   }
 }
 }
 
 
-
-
 } // .crowi.main-container aside.sidebar .side-content
 } // .crowi.main-container aside.sidebar .side-content

+ 14 - 1
resource/css/_comment_crowi-plus.scss

@@ -1,6 +1,7 @@
 .crowi-plus.main-container  {
 .crowi-plus.main-container  {
 
 
   %comment-section {
   %comment-section {
+    position: relative;
     background: #f5f5f5;
     background: #f5f5f5;
     padding: 1em;
     padding: 1em;
     margin-left: 4.5em;
     margin-left: 4.5em;
@@ -10,7 +11,7 @@
       border: 1em solid transparent;
       border: 1em solid transparent;
       border-right-color:#f5f5f5;
       border-right-color:#f5f5f5;
       border-left-width: 0;
       border-left-width: 0;
-      left: 3.5em;
+      left: -1em;
       content: "";
       content: "";
       display: block;
       display: block;
       top: 1.5em;
       top: 1.5em;
@@ -72,6 +73,18 @@
       }
       }
     }
     }
 
 
+    .page-comment-control {
+      position: absolute;
+      display: none;    // default hidden
+      top: 0;
+      right: 0;
+    }
+
+  }
+
+  // show when hover
+  .page-comment-main:hover > .page-comment-control {
+    display: block;
   }
   }
 
 
   .page-comment-old {
   .page-comment-old {

+ 5 - 4
resource/css/_page.scss

@@ -238,10 +238,11 @@
         font-size: 1.1em;
         font-size: 1.1em;
       }
       }
 
 
-      a {
-        color: #ccc;
-        &:hover { color: #aaa;}
-      }
+      // disabled in crowi-plus -- 2017.06.03 Yuki Takei
+      // a {
+      //   color: #ccc;
+      //   &:hover { color: #aaa;}
+      // }
 
 
       ul.fitted-list {
       ul.fitted-list {
         padding-left: 0;
         padding-left: 0;

+ 19 - 5
resource/js/components/PageComment/Comment.js

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 
 
 import moment from 'moment/src/moment';
 import moment from 'moment/src/moment';
 
 
+import ReactUtils from '../ReactUtils';
 import UserPicture from '../User/UserPicture';
 import UserPicture from '../User/UserPicture';
 
 
 /**
 /**
@@ -18,13 +19,15 @@ export default class Comment extends React.Component {
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
-    this.isCurrentUserIsAuthor = this.isCurrentUserIsAuthor.bind(this);
+    this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
     this.isCurrentRevision = this.isCurrentRevision.bind(this);
     this.isCurrentRevision = this.isCurrentRevision.bind(this);
     this.getRootClassName = this.getRootClassName.bind(this);
     this.getRootClassName = this.getRootClassName.bind(this);
+    this.getRevisionLabelClassName = this.getRevisionLabelClassName.bind(this);
+    this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
   }
   }
 
 
-  isCurrentUserIsAuthor() {
-    return this.props.comment.creator._id === this.props.currentUserId;
+  isCurrentUserEqualsToAuthor() {
+    return this.props.comment.creator.username === this.props.currentUserId;
   }
   }
 
 
   isCurrentRevision() {
   isCurrentRevision() {
@@ -33,7 +36,7 @@ export default class Comment extends React.Component {
 
 
   getRootClassName() {
   getRootClassName() {
     return "page-comment "
     return "page-comment "
-        + (this.isCurrentUserIsAuthor() ? 'page-comment-me' : '')
+        + (this.isCurrentUserEqualsToAuthor() ? 'page-comment-me' : '')
         + (this.isCurrentRevision() ? '': 'page-comment-old');
         + (this.isCurrentRevision() ? '': 'page-comment-old');
   }
   }
 
 
@@ -42,12 +45,17 @@ export default class Comment extends React.Component {
         + (this.isCurrentRevision() ? 'label-primary' : 'label-default');
         + (this.isCurrentRevision() ? 'label-primary' : 'label-default');
   }
   }
 
 
+  deleteBtnClickedHandler() {
+    this.props.deleteBtnClicked(this.props.comment);
+  }
+
   render() {
   render() {
     const comment = this.props.comment;
     const comment = this.props.comment;
     const creator = comment.creator;
     const creator = comment.creator;
 
 
     const rootClassName = this.getRootClassName();
     const rootClassName = this.getRootClassName();
     const commentDate = moment(comment.createdAt).format('YYYY/MM/DD HH:mm');
     const commentDate = moment(comment.createdAt).format('YYYY/MM/DD HH:mm');
+    const commentBody = ReactUtils.nl2br(comment.comment);
     const revHref = `?revision=${comment.revision}`;
     const revHref = `?revision=${comment.revision}`;
     const revFirst8Letters = comment.revision.substr(0,8);
     const revFirst8Letters = comment.revision.substr(0,8);
     const revisionLavelClassName = this.getRevisionLabelClassName();
     const revisionLavelClassName = this.getRevisionLabelClassName();
@@ -57,11 +65,16 @@ export default class Comment extends React.Component {
         <UserPicture user={creator} />
         <UserPicture user={creator} />
         <div className="page-comment-main">
         <div className="page-comment-main">
           <div className="page-comment-creator">{creator.username}</div>
           <div className="page-comment-creator">{creator.username}</div>
-          <div className="page-comment-body">{comment.comment.replace(/(\r\n|\r|\n)/g, '<br>')}</div>
+          <div className="page-comment-body">{commentBody}</div>
           <div className="page-comment-meta">
           <div className="page-comment-meta">
             {commentDate}&nbsp;
             {commentDate}&nbsp;
             <a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a>
             <a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a>
           </div>
           </div>
+          <div className="page-comment-control">
+            <a className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
+              <i className="fa fa-trash-o"></i>
+            </a>
+          </div>
         </div>
         </div>
       </div>
       </div>
     );
     );
@@ -72,4 +85,5 @@ Comment.propTypes = {
   comment: PropTypes.object.isRequired,
   comment: PropTypes.object.isRequired,
   currentRevisionId: PropTypes.string.isRequired,
   currentRevisionId: PropTypes.string.isRequired,
   currentUserId: PropTypes.string.isRequired,
   currentUserId: PropTypes.string.isRequired,
+  deleteBtnClicked: PropTypes.func.isRequired,
 };
 };

+ 60 - 0
resource/js/components/PageComment/DeleteCommentModal.js

@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Button, Modal } from 'react-bootstrap';
+import moment from 'moment/src/moment';
+
+import ReactUtils from '../ReactUtils';
+import UserPicture from '../User/UserPicture';
+
+export default class DeleteCommentModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+  }
+
+  componentWillMount() {
+  }
+
+  render() {
+    if (this.props.comment === undefined) {
+      return <div></div>
+    }
+
+    const comment = this.props.comment;
+    const commentDate = moment(comment.createdAt).format('YYYY/MM/DD HH:mm');
+
+    // generate body
+    let commentBody = comment.comment;
+    if (commentBody.length > 200) { // omit
+      commentBody = commentBody.substr(0,200) + '...';
+    }
+    commentBody = ReactUtils.nl2br(commentBody);
+
+    return (
+      <Modal show={this.props.isShown} onHide={this.props.cancel} className="page-comment-delete-modal">
+        <Modal.Header closeButton>
+          <Modal.Title>Delete comment?</Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          <UserPicture user={comment.creator} size="xs" /> <strong>{comment.creator.username}</strong> wrote on {commentDate}:
+          <p className="comment-body">{commentBody}</p>
+        </Modal.Body>
+        <Modal.Footer>
+          <span className="text-danger">{this.props.errorMessage}</span>&nbsp;
+          <Button onClick={this.props.cancel}>Cancel</Button>
+          <Button onClick={this.props.confirmedToDelete} className="btn-danger">Delete</Button>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+}
+
+DeleteCommentModal.propTypes = {
+  isShown: PropTypes.bool.isRequired,
+  comment: PropTypes.object,
+  errorMessage: PropTypes.string,
+  cancel: PropTypes.func.isRequired,            // for cancel evnet handling
+  confirmedToDelete: PropTypes.func.isRequired, // for confirmed event handling
+};

+ 88 - 26
resource/js/components/PageComments.js

@@ -2,6 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import Comment from './PageComment/Comment';
 import Comment from './PageComment/Comment';
+import DeleteCommentModal from './PageComment/DeleteCommentModal';
 
 
 /**
 /**
  * Load data of comments and render the list of <Comment />
  * Load data of comments and render the list of <Comment />
@@ -18,12 +19,19 @@ export default class PageComments extends React.Component {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
-      currentComments: [],
-      newerComments: [],
-      olderComments: [],
+      comments: [],
+
+      // for deleting comment
+      commentToDelete: undefined,
+      isDeleteConfirmModalShown: false,
+      errorMessageForDeleting: undefined,
     };
     };
 
 
     this.init = this.init.bind(this);
     this.init = this.init.bind(this);
+    this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
+    this.deleteComment = this.deleteComment.bind(this);
+    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
+    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
   }
   }
 
 
   componentWillMount() {
   componentWillMount() {
@@ -40,29 +48,11 @@ export default class PageComments extends React.Component {
     }
     }
 
 
     const pageId = this.props.pageId;
     const pageId = this.props.pageId;
-    const revisionId = this.props.revisionId;
-    const revisionCreatedAt = this.props.revisionCreatedAt;
 
 
     this.props.crowi.apiGet('/comments.get', {page_id: pageId})
     this.props.crowi.apiGet('/comments.get', {page_id: pageId})
     .then(res => {
     .then(res => {
       if (res.ok) {
       if (res.ok) {
-        let currentComments = [];
-        let newerComments = [];
-        let olderComments = [];
-
-        // divide by revisionId and createdAt
-        res.comments.forEach((comment) => {
-          if (comment.revision == revisionId) {
-            currentComments.push(comment);
-          }
-          else if (Date.parse(comment.createdAt)/1000 > revisionCreatedAt) {
-            newerComments.push(comment);
-          }
-          else {
-            olderComments.push(comment);
-          }
-        });
-        this.setState({currentComments, newerComments, olderComments});
+        this.setState({comments: res.comments});
       }
       }
     }).catch(err => {
     }).catch(err => {
 
 
@@ -70,6 +60,49 @@ export default class PageComments extends React.Component {
 
 
   }
   }
 
 
+  confirmToDeleteComment(comment) {
+    this.setState({commentToDelete: comment});
+    this.showDeleteConfirmModal();
+  }
+
+  deleteComment() {
+    const comment = this.state.commentToDelete;
+
+    this.props.crowi.apiPost('/comments.remove', {comment_id: comment._id})
+    .then(res => {
+      if (res.ok) {
+        this.findAndSplice(comment);
+      }
+      this.closeDeleteConfirmModal();
+    }).catch(err => {
+      this.setState({errorMessageForDeleting: err.message});
+    });
+  }
+
+  findAndSplice(comment) {
+    let comments = this.state.comments;
+
+    const index = comments.indexOf(comment);
+    if (index < 0) {
+      return;
+    }
+    comments.splice(index, 1);
+
+    this.setState({comments});
+  }
+
+  showDeleteConfirmModal() {
+    this.setState({isDeleteConfirmModalShown: true});
+  }
+
+  closeDeleteConfirmModal() {
+    this.setState({
+      commentToDelete: undefined,
+      isDeleteConfirmModalShown: false,
+      errorMessageForDeleting: undefined,
+    });
+  }
+
   /**
   /**
    * generate Elements of Comment
    * generate Elements of Comment
    *
    *
@@ -82,15 +115,36 @@ export default class PageComments extends React.Component {
       return (
       return (
         <Comment key={comment._id} comment={comment}
         <Comment key={comment._id} comment={comment}
           currentUserId={this.props.crowi.me}
           currentUserId={this.props.crowi.me}
-          currentRevisionId={this.props.revisionId} />
+          currentRevisionId={this.props.revisionId}
+          deleteBtnClicked={this.confirmToDeleteComment} />
       );
       );
     });
     });
   }
   }
 
 
   render() {
   render() {
-    let currentElements = this.generateCommentElements(this.state.currentComments);
-    let newerElements = this.generateCommentElements(this.state.newerComments);
-    let olderElements = this.generateCommentElements(this.state.olderComments);
+    let currentComments = [];
+    let newerComments = [];
+    let olderComments = [];
+
+    // divide by revisionId and createdAt
+    const revisionId = this.props.revisionId;
+    const revisionCreatedAt = this.props.revisionCreatedAt;
+    this.state.comments.forEach((comment) => {
+      if (comment.revision == revisionId) {
+        currentComments.push(comment);
+      }
+      else if (Date.parse(comment.createdAt)/1000 > revisionCreatedAt) {
+        newerComments.push(comment);
+      }
+      else {
+        olderComments.push(comment);
+      }
+    });
+
+    // generate elements
+    let currentElements = this.generateCommentElements(currentComments);
+    let newerElements = this.generateCommentElements(newerComments);
+    let olderElements = this.generateCommentElements(olderComments);
 
 
     let toggleNewer = (newerElements.length === 0)
     let toggleNewer = (newerElements.length === 0)
       ? <div></div>
       ? <div></div>
@@ -120,6 +174,14 @@ export default class PageComments extends React.Component {
         <div className="page-comments-list-older collapse in" id="page-comments-list-older">
         <div className="page-comments-list-older collapse in" id="page-comments-list-older">
           {olderElements}
           {olderElements}
         </div>
         </div>
+
+        <DeleteCommentModal
+          isShown={this.state.isDeleteConfirmModalShown}
+          comment={this.state.commentToDelete}
+          errorMessage={this.state.errorMessageForDeleting}
+          cancel={this.closeDeleteConfirmModal}
+          confirmedToDelete={this.deleteComment}
+        />
       </div>
       </div>
     );
     );
   }
   }

+ 28 - 0
resource/js/components/ReactUtils.js

@@ -0,0 +1,28 @@
+import React from 'react';
+
+export default class ReactUtils {
+
+  /**
+   * show '\n' as '<br>'
+   *
+   * @see http://qiita.com/kouheiszk/items/e7c74ab5eab901f89a7f
+   *
+   * @static
+   * @param {any} text
+   * @returns
+   *
+   * @memberOf ReactUtils
+   */
+  static nl2br(text) {
+    var regex = /(\n)/g
+    return text.split(regex).map(function (line) {
+      if (line.match(regex)) {
+        return React.createElement('br', {key: Math.random().toString(10).substr(2, 10)})
+      }
+      else {
+        return line;
+      }
+    });
+  }
+
+}