Changsung Kim 2014-06-23
`Agree` buttons on each comment are added.
Cause:
There is a VOC. It needs a function that agrees with comments.

Solution:
`Agree` buttons which like heart shape are added on each comments.

Private-issue: 1035
@75484d64f03ea3fc81204d984cefe4d9b1b71f9e
app/assets/stylesheets/less/_page.less
--- app/assets/stylesheets/less/_page.less
+++ app/assets/stylesheets/less/_page.less
@@ -2754,6 +2754,26 @@
                                 color:@yobi-link;
                                 .opacity(100);
                             }
+
+                            &.vote-heart-disable-hover:hover {
+                              color:initial;
+                              opacity:0.2;
+                              font-size:16px;
+                            }
+                        }
+
+                        .vote-description-people {
+                            color:#3b5998
+                        }
+
+                        .vote-heart-on {
+                            font-size:16px;
+                            color:@yobi-link;
+                            opacity:1.0;
+                        }
+
+                        .vote-heart-off {
+                            font-size:16px;
                         }
                     }
 
app/assets/stylesheets/less/_yobiUI.less
--- app/assets/stylesheets/less/_yobiUI.less
+++ app/assets/stylesheets/less/_yobiUI.less
@@ -1126,6 +1126,14 @@
     outline:none;
 }
 
+.btn-transparent-with-fontsize-lineheight {
+    background:transparent;
+    border:0;
+    outline:none;
+    font-size:0px;
+    line-height:normal;
+}
+
 .numberic {
     display:inline-block;
     margin-left:5px;
app/controllers/VoteApp.java
--- app/controllers/VoteApp.java
+++ app/controllers/VoteApp.java
@@ -20,8 +20,13 @@
  */
 package controllers;
 
+import controllers.annotation.IsAllowed;
 import models.Issue;
+import models.IssueComment;
 import models.User;
+import models.enumeration.Operation;
+import org.codehaus.jackson.node.ObjectNode;
+import play.libs.Json;
 import play.mvc.Call;
 import play.mvc.With;
 import models.Project;
@@ -31,6 +36,8 @@
 import actions.AnonymousCheckAction;
 import models.enumeration.ResourceType;
 import controllers.annotation.IsCreatable;
+import utils.Constants;
+import utils.RouteUtil;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -52,7 +59,8 @@
      * @return
      */
     @Transactional
-    @IsCreatable(ResourceType.ISSUE_COMMENT)
+    @With(AnonymousCheckAction.class)
+    @IsAllowed(Operation.READ)
     public static Result vote(String ownerName, String projectName, Long issueNumber) {
 
         Project project = Project.findByOwnerAndProjectName(ownerName, projectName);
@@ -76,7 +84,8 @@
      * @return
      */
     @Transactional
-    @IsCreatable(ResourceType.ISSUE_COMMENT)
+    @With(AnonymousCheckAction.class)
+    @IsAllowed(Operation.READ)
     public static Result unvote(String ownerName, String projectName, Long issueNumber) {
         Project project = Project.findByOwnerAndProjectName(ownerName, projectName);
         Issue issue = Issue.findByNumber(project, issueNumber);
@@ -88,6 +97,38 @@
         return redirect(call);
     }
 
+    @Transactional
+    @With(AnonymousCheckAction.class)
+    @IsAllowed(Operation.READ)
+    public static Result voteComment(String user, String project, Long number, Long commentId) {
+        IssueComment issueComment = IssueComment.find.byId(commentId);
+        if (issueComment == null) {
+            return notFound("issue.comment.error.vote");
+        }
+
+        issueComment.addVoter(UserApp.currentUser());
+
+        return redirect(RouteUtil.getUrl(issueComment));
+    }
+
+    @Transactional
+    @With(AnonymousCheckAction.class)
+    @IsAllowed(Operation.READ)
+    public static Result unvoteComment(String user, String project, Long number, Long commentId) {
+        IssueComment issueComment = IssueComment.find.byId(commentId);
+        if (issueComment == null) {
+            return notFound("issue.comment.error.unvote");
+        }
+
+        if (!issueComment.voters.contains(UserApp.currentUser())) {
+            return notFound("issue.comment.error.have.not.voted");
+        }
+
+        issueComment.removeVoter(UserApp.currentUser());
+
+        return redirect(RouteUtil.getUrl(issueComment));
+    }
+
     public static List<User> getVotersForAvatar(List<User> voters, int size){
         return getSubList(voters, 0, size);
     }
app/models/IssueComment.java
--- app/models/IssueComment.java
+++ app/models/IssueComment.java
@@ -22,10 +22,9 @@
 
 import models.enumeration.ResourceType;
 import models.resource.Resource;
-import org.apache.commons.lang3.builder.EqualsBuilder;
-import org.apache.commons.lang3.builder.HashCodeBuilder;
 
 import javax.persistence.*;
+import java.util.List;
 
 @Entity
 public class IssueComment extends Comment {
@@ -34,6 +33,14 @@
 
     @ManyToOne
     public Issue issue;
+
+    @ManyToMany(cascade = CascadeType.ALL)
+    @JoinTable(
+            name = "issue_comment_voter",
+            joinColumns = @JoinColumn(name = "issue_comment_id"),
+            inverseJoinColumns = @JoinColumn(name = "user_id")
+    )
+    public List<User> voters;
 
     /**
      * @see Comment#getParent()
@@ -69,4 +76,18 @@
             }
         };
     }
+
+    public void addVoter(User user) {
+        if (!this.voters.contains(user)) {
+            this.voters.add(user);
+            this.update();
+        }
+    }
+
+    public void removeVoter(User user) {
+        if (this.voters.contains(user)) {
+            this.voters.remove(user);
+            this.update();
+        }
+    }
 }
app/views/issue/partial_comments.scala.html
--- app/views/issue/partial_comments.scala.html
+++ app/views/issue/partial_comments.scala.html
@@ -63,6 +63,8 @@
     <strong>@Messages("code.commits") <a href="@routes.CodeHistoryApp.show(project.owner, project.name, commitId)" class="link">@{"@"}@commitId</a></strong>
 }
 
+@VOTER_AVATAR_SHOW_LIMIT = @{ 5 }
+
 <div class="comment-header"><i class="yobicon-comments"></i> <strong>@Messages("common.comment")</strong> <strong class="num">@issue.comments.size</strong></div>
 <hr class="nm">
 
@@ -86,12 +88,54 @@
                 </span>
                 <a href="#comment-@comment.id" class="ago" title="@JodaDateUtil.getDateString(comment.createdDate)">@utils.TemplateHelper.agoOrDateString(comment.createdDate)</a>
                 <span class="act-row pull-right">
+                    @if(isAllowed(UserApp.currentUser(), comment.asResource(), Operation.READ) && comment.isInstanceOf[IssueComment]) {
+                        @defining(comment.asInstanceOf[IssueComment]) { issueComment =>
+                            @if(issueComment.voters.size > VOTER_AVATAR_SHOW_LIMIT) {
+                                <span style="margin-right:2px;" data-toggle="tooltip" data-html="true" title="
+                                    @for(voter <- VoteApp.getVotersForName(issueComment.voters, 0, 5)) {
+                                        @voter.name<br>
+                                    }
+                                    &hellip;">
+                                    <a class="vote-description-people" href="#voters-@issueComment.id" data-toggle="modal">
+                                        @if(issueComment.voters.size == 1) {
+                                            @Messages("common.comment.vote.agreement", issueComment.voters.size)
+                                        } else {
+                                            @Messages("common.comment.vote.agreements", issueComment.voters.size)
+                                        }
+                                    </a>
+                                </span>
+
+                                @partial_voter_list("voters-" + issueComment.id, issueComment.voters)
+                            } else {
+                                @for(voter <- issueComment.voters){
+                                    <a href="@routes.UserApp.userInfo(voter.loginId)" class="avatar-wrap smaller" data-toggle="tooltip" data-placement="top" title="@voter.name" style="margin-right:3px;">
+                                        <img src="@User.findByLoginId(voter.loginId).avatarUrl">
+                                    </a>
+                                }
+                            }
+
+                            @if(issueComment.voters.contains(UserApp.currentUser())) {
+                                <button type="button" class="btn-transparent-with-fontsize-lineheight" title="@Messages("common.comment.vote")" data-request-type="comment-vote" data-request-uri="@routes.VoteApp.unvoteComment(project.owner, project.name, issue.getNumber, comment.id)">
+                                <i class="yobicon-hearts vote-heart-on"></i>
+                                </button>
+                            } else {
+                                @if(UserApp.currentUser().isAnonymous()) {
+                                    <i class="yobicon-hearts vote-heart-off vote-heart-disable-hover"></i>
+                                } else {
+                                    <button type="button" class="btn-transparent-with-fontsize-lineheight" title="@Messages("common.comment.vote")" data-request-type="comment-vote" data-request-uri="@routes.VoteApp.voteComment(project.owner, project.name, issue.getNumber, comment.id)">
+                                    <i class="yobicon-hearts vote-heart-off"></i>
+                                    </button>
+                                }
+                            }
+                        }
+                    }
+
                     @if(isAllowed(UserApp.currentUser(), comment.asResource(), Operation.UPDATE)) {
-                        <button type="button" class="btn-transparent mr10" data-toggle="comment-edit" data-comment-id="@comment.id" title="@Messages("common.comment.edit")"><i class="yobicon-edit-2"></i></button>
+                        <button type="button" class="btn-transparent-with-fontsize-lineheight ml10" data-toggle="comment-edit" data-comment-id="@comment.id" title="@Messages("common.comment.edit")"><i class="yobicon-edit-2"></i></button>
                     }
 
                     @if(isAllowed(UserApp.currentUser(), comment.asResource(), Operation.DELETE)) {
-                        <button type="button" class="btn-transparent" data-toggle="comment-delete" data-request-uri="@routes.IssueApp.deleteComment(project.owner, project.name, issue.getNumber, comment.id)" title="@Messages("common.comment.delete")"><i class="yobicon-trash"></i></button>
+                        <button type="button" class="btn-transparent-with-fontsize-lineheight ml10" data-toggle="comment-delete" data-request-uri="@routes.IssueApp.deleteComment(project.owner, project.name, issue.getNumber, comment.id)" title="@Messages("common.comment.delete")"><i class="yobicon-trash"></i></button>
                     }
                 </span>
             </div>
 
app/views/issue/partial_voter_list.scala.html (added)
+++ app/views/issue/partial_voter_list.scala.html
@@ -0,0 +1,47 @@
+@**
+* Yobi, Project Hosting SW
+*
+* Copyright 2014 NAVER Corp.
+* http://yobi.io
+*
+* @Author Changsung Kim
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*   http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+**@
+
+@(id:String, voters:Collection[User])
+
+<div id="@id" class="modal hide voters-dialog">
+    <div class="modal-header">
+        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+        <h5 class="nm">@Messages("issue.voters")</h5>
+    </div>
+    <div class="modal-body">
+        <ul class="unstyled">
+            @for(voter <- voters){
+            <li>
+                <a href="@routes.UserApp.userInfo(voter.loginId)" class="usf-group" target="_blank">
+                    <span class="avatar-wrap mlarge">
+                        <img src="@voter.avatarUrl" width="40" height="40">
+                    </span>
+                    <strong class="name">@voter.name</strong>
+                    <span class="loginid"> <strong>@{"@"}</strong>@voter.loginId</span>
+                </a>
+            </li>
+            }
+        </ul>
+    </div>
+    <div class="modal-footer">
+        <button class="ybtn ybtn-info ybtn-small" data-dismiss="modal" aria-hidden="true">@Messages("button.close")</button>
+    </div>
+</div>
 
conf/evolutions/default/89.sql (added)
+++ conf/evolutions/default/89.sql
@@ -0,0 +1,15 @@
+# --- !Ups
+
+CREATE TABLE issue_comment_voter (
+  issue_comment_id               BIGINT NOT NULL,
+  user_id                        BIGINT NOT NULL,
+  CONSTRAINT pk_issue_comment_voter PRIMARY KEY (issue_comment_id, user_id))
+;
+
+ALTER TABLE issue_comment_voter ADD CONSTRAINT fk_issue_comment_voter_issue FOREIGN KEY (issue_comment_id) REFERENCES issue_comment (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
+ALTER TABLE issue_comment_voter ADD CONSTRAINT fk_issue_comment_voter_n4user FOREIGN KEY (user_id) REFERENCES n4user (id) ON DELETE RESTRICT ON UPDATE RESTRICT;
+
+
+# --- !Downs
+
+DROP TABLE IF EXISTS issue_comment_voter;
conf/messages
--- conf/messages
+++ conf/messages
@@ -41,6 +41,7 @@
 button.back = Back
 button.cancel = Cancel
 button.cancel.enrollment = Cancel sign-up request.
+button.close = Close
 button.comment.new = Add comments.
 button.comment.open = Open comment window
 button.commentAndNextState.closed = Comment & Close issue
@@ -140,6 +141,9 @@
 common.comment.delete = Delete comment
 common.comment.delete.confirm = Once you delete this comment, you won''t be able to recover it. Are you sure you want to delete this comment?
 common.comment.edit = Edit comment
+common.comment.vote = Agree
+common.comment.vote.agreement = {0} Agreement
+common.comment.vote.agreements = {0} Agreements
 common.editor.edit = Edit
 common.editor.preview = Preview
 common.experimental = Experimental function
@@ -221,6 +225,9 @@
 issue.can.not.be.deleted = Failed to delete because of other users'' comments
 issue.comment.delete.confirm = Once you delete this comment, you won''t be able to recover this again. Do you still want to delete it?
 issue.comment.delete.window = Delete issue comment
+issue.comment.error.vote = Failed to agree with the comment because server error has occurred.
+issue.comment.error.unvote = Failed to disagree with the comment because server error has occurred.
+issue.comment.error.have.not.voted = Failed to disagree with the comment because you have not voted this.
 issue.createdDate = Created date
 issue.delete = Delete issue
 issue.downloadAsExcel = Download as Excel file
@@ -256,7 +263,7 @@
 issue.state.closed = Closed
 issue.state.enrolled = Status entered
 issue.state.open = In progress
-issue.unvote.description = Click here if you no longer sympathize with this issue.
+issue.unvote.description = Click here if you no longer agree with this issue.
 issue.unwatch.start = You will no longer get notifications about this issue
 issue.update.assignee.id = Update assignee
 issue.update.attachLabel = Attach label
@@ -266,8 +273,8 @@
 issue.update.milestone.id = Update milestone
 issue.update.state = Update status
 issue.vote = Agree
-issue.vote.description = Click here if you sympathize with this issue.
-issue.voters = People who feels sympathies with this
+issue.vote.description = Click here if you agree with this issue.
+issue.voters = People who agree with this
 issue.voters.more = and {0} others
 issue.watch.start =Now you will get notifications about this issue
 label = Label
conf/messages.ko
--- conf/messages.ko
+++ conf/messages.ko
@@ -41,6 +41,7 @@
 button.back = 돌아가기
 button.cancel = 취소
 button.cancel.enrollment = 멤버 등록 요청 취소하기
+button.close = 닫기
 button.comment.new = 댓글 입력
 button.comment.open = 댓글 입력 창 열기
 button.commentAndNextState.closed = 댓글 입력하고 이슈 닫기
@@ -140,6 +141,9 @@
 common.comment.delete = 댓글 삭제
 common.comment.delete.confirm = 해당 댓글이 삭제되면 영원히 복구할 수 없습니다. 그래도 삭제하시겠습니까?
 common.comment.edit = 댓글 수정
+common.comment.vote = 공감
+common.comment.vote.agreement = {0} 공감
+common.comment.vote.agreements = {0} 공감
 common.editor.edit = 편집
 common.editor.preview = 미리보기
 common.experimental = 실험적인 기능
@@ -222,6 +226,9 @@
 issue.can.not.be.deleted = 다른 사용자의 댓글이 있어 삭제할 수 없습니다.
 issue.comment.delete.confirm = 해당 이슈의 댓글을 삭제하시겠습니까?
 issue.comment.delete.window = 이슈 댓글 삭제
+issue.comment.error.vote = 서버 오류가 발생하여 댓글에 공감을 할 수 없습니다.
+issue.comment.error.unvote = 서버 오류가 발생하여 댓글에 공감 취소를 할 수 없습니다.
+issue.comment.error.have.not.voted = 이 댓글에 공감하지 않아서 공감 취소를 할 수 없습니다.
 issue.createdDate = 작성일
 issue.delete = 이슈 삭제
 issue.downloadAsExcel = 엑셀파일로 다운받기
conf/routes
--- conf/routes
+++ conf/routes
@@ -281,6 +281,8 @@
 # Vote
 POST           /:user/:project/issue/:number/vote                                     controllers.VoteApp.vote(user, project, number: Long)
 POST           /:user/:project/issue/:number/unvote                                   controllers.VoteApp.unvote(user, project, number: Long)
+POST           /:user/:project/issue/:number/comment/:commentId/vote                  controllers.VoteApp.voteComment(user, project, number: Long, commentId: Long)
+POST           /:user/:project/issue/:number/comment/:commentId/unvote                controllers.VoteApp.unvoteComment(user, project, number: Long, commentId: Long)
 
 # Comment Thread
 POST           /threads/:id/open                                                      controllers.CommentThreadApp.open(id: Long)
docs/ko/technical/access-control.md
--- docs/ko/technical/access-control.md
+++ docs/ko/technical/access-control.md
@@ -18,7 +18,14 @@
 * 멤버: 저장소 삭제(이 기능은 아직 없다)를 제외한 모든 권한
 * 그 외의 모든 사용자: 모든 읽기 권한
 
-이슈, 게시판
+이슈
+---
+
+* 멤버: 모든 권한
+* 로그인 사용자: 모든 읽기 권한, 게시물/댓글 등록, 게시물/댓글 공감
+* 비로그인 사용자: 모든 읽기 권한
+
+게시판
 ------------
 
 * 멤버: 모든 권한
public/javascripts/service/yobi.issue.View.js
--- public/javascripts/service/yobi.issue.View.js
+++ public/javascripts/service/yobi.issue.View.js
@@ -50,7 +50,7 @@
          *
          * @private
          */
-        function _initElement(){
+        function _initElement(options){
             elements.uploader = $("#upload");
             elements.textarea = $('textarea[data-editor-mode="comment-body"]');
 
@@ -61,6 +61,8 @@
             elements.timelineList = elements.timelineWrap.find(".timeline-list");
 
             elements.dueDate = $("#issueDueDate");
+
+            elements.btnVoteComment = $(options.btnVoteComment || '[data-request-type="comment-vote"]');
         }
 
         /**
@@ -94,6 +96,9 @@
             // Watch button
             elements.btnWatch.on("click", _onClickBtnWatch);
 
+            // Vote button on comment
+            elements.btnVoteComment.on("click", _onClickCommentVote);
+
             // Update issue info
             elements.issueInfoWrap.on("change", "[data-toggle=select2]", _onChangeIssueInfo);
             elements.issueInfoWrap.on("change", "[data-toggle=calendar]", _onChangeIssueInfo);
@@ -105,6 +110,18 @@
             });
         }
 
+        function _onClickCommentVote(){
+            $.ajax($(this).data("requestUri"), {
+                "method"  : "post",
+                "success" : function(){
+                    location.reload();
+                },
+                "error" : function(res){
+                    $yobi.notify(Messages(res.responseText), 3000);
+                }
+            });
+        }
+
         /**
          * "change" event handler of issue info select2 fields.
          *
Add a comment
List