doortts doortts 2018-06-10
issue: Support favorite issue feature
@cd230c64ecafa5518a6a93f25855bb3065b1aa61
app/assets/stylesheets/less/_page.less
--- app/assets/stylesheets/less/_page.less
+++ app/assets/stylesheets/less/_page.less
@@ -7273,3 +7273,22 @@
         white-space: nowrap;
     }
 }
+
+.favorite-issue {
+    .my-issue {
+        font-size: 16px !important;
+    }
+
+    .star {
+        color: rgba(0, 0, 0, 0.2);
+        font-size: 20px;
+
+        &:hover {
+            color: #e91e63;
+            cursor: pointer;
+        }
+    }
+    .starred {
+        color: #e91e63 !important;
+    }
+}
app/controllers/IssueApp.java
--- app/controllers/IssueApp.java
+++ app/controllers/IssueApp.java
@@ -73,7 +73,7 @@
         // SearchCondition from param
         Form<models.support.SearchCondition> issueParamForm = new Form<>(models.support.SearchCondition.class);
         models.support.SearchCondition searchCondition = issueParamForm.bindFromRequest().get();
-        if (hasNotConditions(searchCondition)) {
+        if (!searchCondition.hasCondition()) {
             searchCondition.assigneeId = UserApp.currentUser().id;
         }
         searchCondition.pageNum = pageNum - 1;
@@ -104,11 +104,6 @@
             default:
                 return issuesAsHTML(project, issues, searchCondition);
         }
-    }
-
-    private static boolean hasNotConditions(models.support.SearchCondition searchCondition) {
-        return searchCondition.assigneeId == null && searchCondition.authorId == null && searchCondition.mentionId == null
-                && searchCondition.commenterId == null && searchCondition.sharerId == null;
     }
 
     @Transactional
app/controllers/api/UserApi.java
--- app/controllers/api/UserApi.java
+++ app/controllers/api/UserApi.java
@@ -9,6 +9,7 @@
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 import controllers.UserApp;
+import models.FavoriteIssue;
 import models.FavoriteOrganization;
 import models.FavoriteProject;
 import models.User;
@@ -58,6 +59,42 @@
         return ok(json);
     }
 
+    @Transactional
+    public static Result toggleFoveriteIssue(String issueId) {
+        if (issueId == null) {
+            return badRequest("Wrong issue id");
+        }
+        boolean isFavored = UserApp.currentUser().toggleFavoriteIssue(Long.valueOf(issueId));
+        ObjectNode json = Json.newObject();
+        json.put("issueId", issueId);
+        json.put("favored", isFavored);
+
+        if(isFavored) {
+            json.put("message", Messages.get("issue.favorite.added"));
+        } else {
+            json.put("message", Messages.get("issue.favorite.deleted"));
+        }
+
+        return ok(json);
+    }
+
+    @Transactional
+    public static Result getFoveriteIssues() {
+        ObjectNode json = Json.newObject();
+        List<ObjectNode> issues = new ArrayList<>();
+        List<Long> issueIds = new ArrayList<>();
+        for (FavoriteIssue favoriteIssue : UserApp.currentUser().favoriteIssues) {
+            ObjectNode project = Json.newObject();
+            project.put("issueId", favoriteIssue.issue.id);
+            project.put("issueTitle", favoriteIssue.issue.title);
+            project.put("issueAuthorName", favoriteIssue.issue.author.getPureNameOnly());
+            issues.add(project);
+            issueIds.add(favoriteIssue.issue.id);
+        }
+        json.put("projectIds", toJson(issueIds));
+        json.put("projects", toJson(issues));
+        return ok(json);
+    }
 
     @Transactional
     public static Result toggleFoveriteOrganization(String organizationId) {
 
app/models/FavoriteIssue.java (added)
+++ app/models/FavoriteIssue.java
@@ -0,0 +1,59 @@
+/**
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp.
+ * https://yona.io
+ **/
+package models;
+
+import models.enumeration.State;
+import play.db.ebean.Model;
+
+import javax.annotation.Nonnull;
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import javax.persistence.ManyToOne;
+import javax.persistence.OneToOne;
+import java.util.List;
+
+@Entity
+public class FavoriteIssue extends Model {
+    public static Finder<Long, FavoriteIssue> find = new Finder<>(Long.class, FavoriteIssue.class);
+
+    @Id
+    public Long id;
+
+    @ManyToOne
+    public User user;
+
+    @OneToOne
+    public Issue issue;
+
+    public FavoriteIssue(User user, Issue issue) {
+        this.user = user;
+        this.issue = issue;
+    }
+
+    public static void updateFavoriteIssue(@Nonnull Issue issue){
+        List<FavoriteIssue> favoriteIssues = find.where().eq("issue.id", issue.id).findList();
+
+        for (FavoriteIssue favoriteProject : favoriteIssues) {
+            favoriteProject.issue.refresh();
+            favoriteProject.update();
+        }
+    }
+
+    public static FavoriteIssue findByIssueId(Long userId, Long issueId){
+        return find.where()
+                .eq("user.id", userId)
+                .eq("issue.id", issueId)
+                .findUnique();
+    }
+
+    public static int getNumberOpenFavoriteIssues(Long userId){
+        return find.where()
+                .eq("user.id", userId)
+                .eq("issue.state", State.OPEN)
+                .findRowCount();
+    }
+}
app/models/User.java
--- app/models/User.java
+++ app/models/User.java
@@ -135,6 +135,9 @@
     @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
     public List<OrganizationUser> groupUser;
 
+    @OneToMany(mappedBy = "user")
+    public List<FavoriteIssue> favoriteIssues;
+
     /**
      * project which is requested member join
      */
@@ -984,6 +987,50 @@
         }
     }
 
+    public List<Issue> getFavoriteIssues() {
+        List<Issue> issues = new ArrayList<>();
+        for (FavoriteIssue favoriteIssue : this.favoriteIssues) {
+            favoriteIssue.issue.refresh();
+            issues.add(0, favoriteIssue.issue);
+        }
+
+        return issues;
+    }
+
+    public void updateFavoriteIssue(@Nonnull Issue issue){
+        for (FavoriteIssue favoriteIssue : this.favoriteIssues) {
+            if (favoriteIssue.issue.id.equals(issue.id)) {
+                favoriteIssue.issue.refresh();
+            }
+        }
+    }
+
+    public boolean toggleFavoriteIssue(Long issueId) {
+        for (FavoriteIssue favoriteIssue : this.favoriteIssues) {
+            if( favoriteIssue.issue.id.equals(issueId) ){
+                removeFavoriteIssue(issueId);
+                this.favoriteIssues.remove(favoriteIssue);
+                return false;
+            }
+        }
+
+        FavoriteIssue favoriteIssue = new FavoriteIssue(this, Issue.finder.byId(issueId));
+        this.favoriteIssues.add(favoriteIssue);
+        favoriteIssue.save();
+        return true;
+    }
+
+    public void removeFavoriteIssue(Long issueId) {
+        List<FavoriteIssue> list = FavoriteIssue.find.where()
+                .eq("user.id", this.id)
+                .eq("issue.id", issueId).findList();
+
+        if(list != null && list.size() > 0){
+            favoriteIssues.remove(list.get(0));
+            list.get(0).delete();
+        }
+    }
+
     public List<Project> getIssueMovableProject(){
         Set<Project> projects = new LinkedHashSet<>();
         projects.addAll(getFavoriteProjects());
app/models/support/SearchCondition.java
--- app/models/support/SearchCondition.java
+++ app/models/support/SearchCondition.java
@@ -34,6 +34,8 @@
 
     public Long mentionId;
     public Long sharerId;
+    public Long favoriteId;
+
     public Organization organization;
     public List<String> projectNames;
 
@@ -63,6 +65,7 @@
         one.commenterId = this.commenterId;
         one.mentionId = this.mentionId;
         one.sharerId = this.sharerId;
+        one.favoriteId = this.favoriteId;
         one.dueDate = this.dueDate;
         one.projectNames = this.projectNames;
         return one;
@@ -116,6 +119,7 @@
         setAuthorIfExist(el);
         setMentionedIssuesIfExist(el);
         setSharedIssuesIfExist(el);
+        setFavoriteIssuesIfExist(el);
         setFilteredStringIfExist(el);
 
         if (commentedCheck) {
@@ -188,12 +192,7 @@
             if(!commenter.isAnonymous()) {
                 List<Long> ids = getCommentedIssueIds(byUser, project);
 
-                if (ids.isEmpty()) {
-                    // No need to progress because the query matches nothing.
-                    el.idEq(-1);
-                } else {
-                    el.idIn(ids);
-                }
+                updateElWhenIdsEmpty(el, ids);
             }
         }
     }
@@ -233,6 +232,7 @@
         setCommenterIfExist(el, null);
         setMentionedIssuesIfExist(el);
         setSharedIssuesIfExist(el);
+        setFavoriteIssuesIfExist(el);
         setFilteredStringIfExist(el);
         setIssueState(el);
         setOrderByIfExist(el);
@@ -261,12 +261,7 @@
             if(!byUser.isAnonymous()) {
                 List<Long> ids = Mention.getMentioningIssueIds(byUser.id);
 
-                if (ids.isEmpty()) {
-                    // No need to progress because the query matches nothing.
-                    el.idEq(-1);
-                } else {
-                    el.idIn(ids);
-                }
+                updateElWhenIdsEmpty(el, ids);
             }
         }
     }
@@ -277,12 +272,26 @@
             if(!byUser.isAnonymous()) {
                 List<Long> ids = getSharedIssueIds(byUser);
 
-                if (ids.isEmpty()) {
-                    // No need to progress because the query matches nothing.
-                    el.idEq(-1);
-                } else {
-                    el.idIn(ids);
-                }
+                updateElWhenIdsEmpty(el, ids);
+            }
+        }
+    }
+
+    private void updateElWhenIdsEmpty(ExpressionList<Issue> el, List<Long> ids) {
+        if (ids.isEmpty()) {
+            // No need to progress because the query matches nothing.
+            el.idEq(-1);
+        } else {
+            el.idIn(ids);
+        }
+    }
+
+    private void setFavoriteIssuesIfExist(ExpressionList<Issue> el) {
+        if (favoriteId != null) {
+            if(!byUser.isAnonymous()) {
+                List<Long> ids = getFavoriteIssueIds(byUser);
+
+                updateElWhenIdsEmpty(el, ids);
             }
         }
     }
@@ -314,6 +323,18 @@
                 .findList();
         for (IssueSharer issueSharer : issueSharers) {
             ids.add(issueSharer.issue.id);
+        }
+
+        return new ArrayList<>(ids);
+    }
+
+    private List<Long> getFavoriteIssueIds(User user) {
+        Set<Long> ids = new HashSet<>();
+        List<FavoriteIssue> favoriteIssues = FavoriteIssue.find.where()
+                .eq("user.id", user.id)
+                .findList();
+        for (FavoriteIssue favoriteIssue : favoriteIssues) {
+            ids.add(favoriteIssue.issue.id);
         }
 
         return new ArrayList<>(ids);
@@ -364,6 +385,7 @@
 
         setCommenterIfExist(el, project);
         setSharedIssuesIfExist(el);
+        setFavoriteIssuesIfExist(el);
         setIssueState(el);
         setLabelsIfExist(project, el);
         setOrderByIfExist(el);
@@ -433,6 +455,15 @@
         return sdf.format(this.dueDate);
     }
 
+    public boolean hasCondition(){
+        return !(assigneeId == null
+                && authorId == null
+                && mentionId == null
+                && commenterId == null
+                && sharerId == null
+                && favoriteId == null);
+    }
+
     @Override
     public String toString() {
         return "SearchCondition{" +
@@ -445,6 +476,7 @@
                 ", project=" + project +
                 ", mentionId=" + mentionId +
                 ", sharerId=" + sharerId +
+                ", favoriteId=" + favoriteId +
                 ", organization=" + organization +
                 ", projectNames=" + projectNames +
                 ", commenterId=" + commenterId +
app/views/issue/my_partial_list_quicksearch.scala.html
--- app/views/issue/my_partial_list_quicksearch.scala.html
+++ app/views/issue/my_partial_list_quicksearch.scala.html
@@ -21,7 +21,8 @@
             data-commenter-id=""
             data-milestone-id="@param.milestoneId"
             data-mention-id=""
-            data-sharer-id="">
+            data-sharer-id=""
+            data-favorite-id="">
             @Messages("issue.list.assignedToMe")
         </a>
     </li>
@@ -32,7 +33,8 @@
             data-commenter-id=""
             data-milestone-id="@param.milestoneId"
             data-mention-id=""
-            data-sharer-id="">
+            data-sharer-id=""
+            data-favorite-id="">
             @Messages("issue.list.authoredByMe")
         </a>
     </li>
@@ -43,7 +45,8 @@
         data-commenter-id="@currentUserId"
         data-milestone-id="@param.milestoneId"
         data-mention-id=""
-        data-sharer-id="">
+        data-sharer-id=""
+        data-favorite-id="">
         @Messages("issue.list.commentedByMe")
         </a>
     </li>
@@ -54,7 +57,8 @@
             data-commenter-id=""
             data-milestone-id="@param.milestoneId"
             data-mention-id="@currentUserId"
-            data-sharer-id="">
+            data-sharer-id=""
+            data-favorite-id="">
             @Messages("issue.list.mentionedOfMe") @if(StringUtils.isBlank(param.filter)) {
                 (@Issue.getCountOfMentionedOpenIssues(currentUserId))
             }
@@ -67,11 +71,30 @@
         data-commenter-id=""
         data-milestone-id="@param.milestoneId"
         data-mention-id=""
-        data-sharer-id="@currentUserId">
+        data-sharer-id="@currentUserId"
+        data-favorite-id="">
         @Messages("issue.list.sharedWithMe") @if(StringUtils.isBlank(param.filter)) {
             (@IssueSharer.getNumberOfIssuesSharedWithUser(currentUserId))
         }
         </a>
     </li>
+        <li @if(param.favoriteId == currentUserId) {
+            class="active"}>
+            <a pjax-filter href="#"
+            data-author-id=""
+            data-assignee-id=""
+            data-commenter-id=""
+            data-milestone-id="@param.milestoneId"
+            data-mention-id=""
+            data-sharer-id=""
+            data-favorite-id="@currentUserId">
+                <span class="favorite-issue">
+                <i class="starred star material-icons va-text-top my-issue">star</i>@Messages("issue.list.favorite")
+                </span>
+                @if(StringUtils.isBlank(param.filter)) {
+                (@FavoriteIssue.getNumberOpenFavoriteIssues(currentUserId))
+            }
+            </a>
+        </li>
     }
 </ul>
app/views/issue/my_partial_search.scala.html
--- app/views/issue/my_partial_search.scala.html
+++ app/views/issue/my_partial_search.scala.html
@@ -36,6 +36,7 @@
                 <input type="hidden" name="assigneeId" value="@param.assigneeId" data-search="assigneeId">
                 <input type="hidden" name="mentionId" value="@param.mentionId" data-search="mentionId">
                 <input type="hidden" name="sharerId" value="@param.sharerId" data-search="sharerId">
+                <input type="hidden" name="favoriteId" value="@param.favoriteId" data-search="favoriteId">
                 <div class="search myissues-search-input">
                     <div class="search-bar">
                         <input name="filter" class="textbox full" type="text" value="@param.filter" placeholder="@Messages("issue.search")">
app/views/issue/view.scala.html
--- app/views/issue/view.scala.html
+++ app/views/issue/view.scala.html
@@ -93,6 +93,10 @@
     found
 }
 
+@isFavoriteIssue = @{
+    FavoriteIssue.findByIssueId(UserApp.currentUser().id, issue.id) != null
+}
+
 @projectLayout(titleForOGTag, project, utils.MenuType.ISSUE){
 @projectMenu(project, utils.MenuType.ISSUE, "main-menu-only")
 <div class="page-wrap-outer">
@@ -110,6 +114,9 @@
                     <span class="subtask-mark">subtask</span>
                 }
                 <strong class="board-id">#@issue.getNumber</strong> @issue.title
+                <span class="favorite-issue" data-issue-id="@issue.id">
+                    <i class="@if(isFavoriteIssue){starred} star material-icons va-text-top">star</i>
+                </span>
                 <div class="pull-right hide show-in-mobile" style="font-size: 0.7em">
                     <span class="date" title="@JodaDateUtil.getDateString(issue.createdDate)">
                     @agoOrDateString(issue.createdDate)
app/views/layout.scala.html
--- app/views/layout.scala.html
+++ app/views/layout.scala.html
@@ -55,6 +55,8 @@
         var UsermenuToggleFavoriteProjectUrl = "@api.routes.UserApi.toggleFoveriteProject("")";
         var UsermenuToggleFoveriteOrganizationUrl = "@api.routes.UserApi.toggleFoveriteOrganization("")";
         var UsermenuGetFoveriteProjectsUrl = "@api.routes.UserApi.getFoveriteProjects()";
+        var UsermenuToggleFavoriteIssueUrl = "@api.routes.UserApi.toggleFoveriteIssue("")";
+        var UsermenuGetFoveriteIssuesUrl = "@api.routes.UserApi.getFoveriteIssues()";
         var UsermenuUrl = "@routes.UserApp.usermenuTabContentList()";
     </script>
     <script type="text/javascript" src="@routes.Assets.at("javascripts/common/yona.Usermenu.js")"></script>
 
conf/evolutions/default/23.sql (added)
+++ conf/evolutions/default/23.sql
@@ -0,0 +1,18 @@
+# --- !Ups
+CREATE TABLE favorite_issue (
+  id                        BIGINT AUTO_INCREMENT NOT NULL,
+  user_id                   BIGINT,
+  issue_id                BIGINT,
+  CONSTRAINT pk_favorite_issue PRIMARY KEY (id),
+  CONSTRAINT uq_favorite_issue_user_id_issue_id_1 UNIQUE (user_id, issue_id),
+  CONSTRAINT fk_favorite_issue_user FOREIGN KEY (user_id) REFERENCES n4user (id) on DELETE CASCADE,
+  CONSTRAINT fk_favorite_issue_issue FOREIGN KEY (issue_id) REFERENCES issue (id) on DELETE CASCADE
+  )
+  row_format=compressed, key_block_size=8
+;
+
+CREATE index ix_favorite_issue_user_1 ON favorite_issue (user_id);
+CREATE index ix_favorite_issue_project_2 ON favorite_issue (issue_id);
+
+# --- !Downs
+DROP TABLE favorite_issue;
conf/messages
--- conf/messages
+++ conf/messages
@@ -296,6 +296,8 @@
 issue.event.sharer.deleted = {0} cancelled issue sharing with {1}
 issue.event.sharer.deleted.title = Cancelled
 issue.event.unassigned = {0} set assignee to unassigned
+issue.favorite.added = Added as a favorite issue. You can see it at my issues page
+issue.favorite.deleted = Removed from a favorite issue
 issue.is.empty = No issue found
 issue.label = Issue Label
 issue.list.all = All issues
@@ -304,6 +306,7 @@
 issue.list.assignedToMe = Assigned
 issue.list.authoredByMe = Created
 issue.list.commentedByMe = Commented
+issue.list.favorite = Favorite
 issue.list.mentionedOfMe = Mentioned
 issue.list.sharedWithMe = Shared
 issue.menu.new = New issue
conf/messages.ko-KR
--- conf/messages.ko-KR
+++ conf/messages.ko-KR
@@ -296,6 +296,8 @@
 issue.event.sharer.deleted = {0} 님이 {1}님을 공유대상에서 제외했습니다.
 issue.event.sharer.deleted.title = 공유 취소
 issue.event.unassigned = {0}님이 이 이슈의 담당자를 "없음"으로 설정하였습니다.
+issue.favorite.added = 즐겨 찾는 이슈로 등록되었습니다. 내 이슈 페이지에서 확인 가능합니다.
+issue.favorite.deleted = 즐겨 찾는 이슈에서 제외되었습니다.
 issue.is.empty = 등록된 이슈가 없습니다.
 issue.label = 이슈 라벨
 issue.list.all = 전체 이슈
@@ -304,6 +306,7 @@
 issue.list.assignedToMe = 할당된 이슈
 issue.list.authoredByMe = 작성한 이슈
 issue.list.commentedByMe = 댓글 남긴 이슈
+issue.list.favorite = 즐겨 찾는 이슈
 issue.list.mentionedOfMe = 나를 언급한 이슈
 issue.list.sharedWithMe = 공유된 이슈
 issue.menu.new = 새 이슈
conf/routes
--- conf/routes
+++ conf/routes
@@ -53,6 +53,8 @@
 POST           /-_-api/v1/favoriteProjects/:projectId                                  controllers.api.UserApi.toggleFoveriteProject(projectId:String)
 GET            /-_-api/v1/favoriteOrganizations                                        controllers.api.UserApi.getFoveriteOrganizations
 POST           /-_-api/v1/favoriteOrganizations/:organizationId                        controllers.api.UserApi.toggleFoveriteOrganization(organizationId:String)
+GET            /-_-api/v1/favoriteIssues                                               controllers.api.UserApi.getFoveriteIssues
+POST           /-_-api/v1/favoriteIssues/:issueId                                      controllers.api.UserApi.toggleFoveriteIssue(issueId:String)
 GET            /-_-api/v1/owners/:owner/projects/:projectName/issues/:number/assignableUsers                                                   controllers.api.IssueApi.findAssignableUsers(owner:String, projectName:String, number:Long, query: String ?= "")
 GET            /-_-api/v1/owners/:owner/projects/:projectName/assignableUsers                                                   controllers.api.IssueApi.findAssignableUsersOfProject(owner:String, projectName:String, query: String ?= "")
 POST           /-_-api/v1/owners/:owner/projects/:projectName/issues/:number/assignees                                                   controllers.api.IssueApi.updateAssginees(owner:String, projectName:String, number:Long)
public/javascripts/common/yona.Usermenu.js
--- public/javascripts/common/yona.Usermenu.js
+++ public/javascripts/common/yona.Usermenu.js
@@ -124,6 +124,26 @@
                 });
         });
 
+        $(".board-header > .title > .favorite-issue").on("click", function toggleProjectFavorite(e) {
+            e.stopPropagation();
+            var that = $(this);
+            $.post(UsermenuToggleFavoriteIssueUrl + that.data("issueId"))
+                .done(function (data) {
+                    if(data.favored){
+                        that.find('i').addClass("starred");
+                    } else {
+                        that.find('i').removeClass("starred");
+                        removeIfNotFavoriteProject(that);
+                    }
+                    $yobi.notify(Messages(data.message), 3000);
+                })
+                .fail(function (data) {
+                    $yobi.alert("Update failed: " + JSON.parse(data.responseText).reason);
+                });
+
+        });
+
+
         function removeIfNotFavoriteProject(that) {
             var $recentlyVisited = $('.user-li');
             var lastFavoriteItemIndex = $recentlyVisited.index($(".favored"));
@@ -133,6 +153,15 @@
             }
         }
 
+        function removeIfNotFavoriteIssue(that) {
+            var $recentlyVisited = $('.user-li');
+            var lastFavoriteItemIndex = $recentlyVisited.index($(".favored"));
+            var currentItemIndex = $recentlyVisited.index(that.parent(".issue-list").parent());
+            if (lastFavoriteItemIndex < currentItemIndex) {
+                that.parent(".issue-list").remove();
+            }
+        }
+
         $(".user-ul > .user-li").on("click", function (e) {
             var location = $(this).data('location');
             if(e.metaKey || e.ctrlKey) {
Add a comment
List