doortts doortts 2017-03-02
subtask: Support basic subtask feature
@caf0ee125f59302ef04c36c0774c77cc0455b8e5
app/assets/stylesheets/less/_common.less
--- app/assets/stylesheets/less/_common.less
+++ app/assets/stylesheets/less/_common.less
@@ -114,6 +114,8 @@
         filter:none;
         &.orange { background:@orange; .box-shadow(none); } // #f28149
         &.blue   { background:@blue;   .box-shadow(none); }
+        &.grey   { background:@gray-9e;   .box-shadow(none); }
+        &.done   { background:@light-green;   .box-shadow(none); }
         &.open   { background:@state-open; .box-shadow(none); }
         &.closed { background:@state-closed; .box-shadow(none); }
     }
app/assets/stylesheets/less/_page.less
--- app/assets/stylesheets/less/_page.less
+++ app/assets/stylesheets/less/_page.less
@@ -2134,43 +2134,7 @@
             background: #fd6956 !important;
         }
 
-        .state-label {
-            font-size: 13px;
-            padding: 5px 0 3px;
-            display: inline-block;
-            min-width: 13px;
-            color: white;
-            border-radius: 5px;
-            text-align: center;
-            line-height: 14px;
-            margin-right: 3px;
-
-            &.open {
-                color: white;
-            }
-            &.closed {
-                color: #2196F3;
-            }
-        }
-
-        .issue-item {
-            font-size: 16px;
-            padding: 3px;
-            .item-name {
-                background-color: white;
-                vertical-align: middle;
-            }
-            .number {
-                font-family: menlo, Consolas, monospace, sans-serif;
-                font-size: 12px;
-                color: gray;
-                min-width: 35px;
-                padding-right: 5px;
-                display: inline-block;
-                text-align: right;
-                margin-right: 3px;
-            }
-        }
+      #simple-issue-list;
 
         .infos {
             width: 100%; /*700px*; /*660px;*/
@@ -3450,7 +3414,9 @@
     .title {
         margin-bottom: 15px;
         font-size:18px;
-
+        border: none;
+        border-bottom: 1px solid #ddd;
+        border-radius: 0 !important;
 	}
 	.error {
 		margin-bottom: 5px;
@@ -6658,3 +6624,101 @@
     color: darkviolet;
     font-weight: bold;
 }
+
+.width16px {
+    width: 16px;
+    display: inline-block;
+}
+
+#simple-issue-list {
+    .state-label {
+        font-size: 13px;
+        padding: 5px 0 3px;
+        display: inline-block;
+        min-width: 13px;
+        color: white;
+        border-radius: 5px;
+        text-align: center;
+        line-height: 14px;
+        margin-right: 3px;
+
+        &.open {
+            color: white;
+        }
+        &.closed {
+            color: #fd6956;
+        }
+    }
+
+    .issue-item {
+        font-size: 16px;
+        padding: 3px;
+        .item-name {
+            background-color: white;
+            vertical-align: middle;
+        }
+        .number {
+            font-family: menlo, Consolas, monospace, sans-serif;
+            font-size: 12px;
+            color: gray;
+            min-width: 35px;
+            padding-right: 5px;
+            display: inline-block;
+            text-align: right;
+            margin-right: 3px;
+        }
+    }
+}
+
+.subtasks {
+    #simple-issue-list;
+    margin-bottom: 15px;
+}
+
+.subtask {
+    a {
+        border: 1px solid;
+        padding: 0 2px;
+      &:hover {
+          color: #51aacc;
+          text-decoration: none;
+      }
+    }
+    color: #9e9e9e;
+}
+
+.infos, .parent-issue {
+    .upload-progress {
+        display: inline-block;
+        width: 30px;
+        vertical-align: middle;
+        overflow: hidden;
+        margin-top: 3px;
+        box-shadow: none;
+    }
+    .completion-ratio {
+        margin-right: 3px;
+    }
+    .open {
+        background-color: #8BC34A;
+    }
+    .closed {
+        background-color: #f68c52;
+    }
+    .parent-issue-state {
+        border-radius: 0;
+        color: white;
+        padding: 2px;
+        font-size: 12px;
+        line-height: 17px;
+        vertical-align: text-bottom;
+    }
+}
+
+.txt-orange {
+    color: #f68c52;
+}
+
+.txt-green {
+    color: @light-green;
+}
app/assets/stylesheets/less/_variables.less
--- app/assets/stylesheets/less/_variables.less
+++ app/assets/stylesheets/less/_variables.less
@@ -22,11 +22,14 @@
 @low-white: #efefef;
 @black  : #000;
 @orange : #F36C22;
+@orangeLighter : #f68c52;
 @purple : #8d008d;
 @blue   : #5DBBE0;
 @blue2  : #51AACC;
 @darkgray: #878787;
 @green: #007fca;
+@light-green: #8bc34a;
+@light-cyan: #00BCD4;
 @darkmagenta: #8B008B;
 
 @gray-d4: #D4D4D4;
@@ -42,6 +45,7 @@
 @gray-52: #5D5D5D;
 @gray-f2: #F2F2F2;
 @gray-d9: #D9D9D9;
+@gray-9e: #9E9E9E;
 
 // new color scheme
 
app/controllers/IssueApp.java
--- app/controllers/IssueApp.java
+++ app/controllers/IssueApp.java
@@ -417,6 +417,9 @@
                     ErrorViews.RequestTextEntityTooLarge.render());
         }
 
+        if(StringUtils.isNotEmpty(newIssue.parentIssueId)){
+            newIssue.parent = Issue.finder.byId(Long.valueOf(newIssue.parentIssueId));
+        }
         newIssue.createdDate = JodaDateUtil.now();
         newIssue.updatedDate = JodaDateUtil.now();
         newIssue.setAuthor(UserApp.currentUser());
@@ -511,6 +514,7 @@
         Project project = Project.findByOwnerAndProjectName(ownerName, projectName);
 
         if (issueForm.hasErrors()) {
+            flash(Constants.WARNING, issueForm.error("name").message());
             return badRequest(edit.render("error.validation", issueForm, Issue.findByNumber(project, number), project));
         }
 
@@ -521,6 +525,7 @@
         issue.dueDate = JodaDateUtil.lastSecondOfDay(issue.dueDate);
 
         final Issue originalIssue = Issue.findByNumber(project, number);
+        updateSubtaskRelation(issue, originalIssue);
 
         Call redirectTo = routes.IssueApp.issue(project.owner, project.name, number);
 
@@ -547,6 +552,16 @@
         return editPosting(originalIssue, issue, issueForm, redirectTo, preUpdateHook);
     }
 
+    private static void updateSubtaskRelation(Issue issue, Issue originalIssue) {
+        if(StringUtils.isEmpty(issue.parentIssueId)){
+            issue.parent = null;
+        } else {
+            issue.parent = Issue.finder.byId(Long.valueOf(issue.parentIssueId));
+        }
+        originalIssue.parent = issue.parent;
+        originalIssue.update();
+    }
+
     private static void setAssignee(Form<Issue> issueForm, Issue issue, Project project) {
         String value = issueForm.field("assignee.user.id").value();
         if (value != null) {
app/models/Issue.java
--- app/models/Issue.java
+++ app/models/Issue.java
@@ -7,6 +7,7 @@
 package models;
 
 import com.avaje.ebean.Ebean;
+import com.avaje.ebean.ExpressionList;
 import com.avaje.ebean.Page;
 import com.avaje.ebean.annotation.Formula;
 import controllers.routes;
@@ -93,6 +94,12 @@
         super(project, author, title, body);
         this.state = State.OPEN;
     }
+
+    @Transient
+    public String parentIssueId;
+
+    @OneToOne
+    public Issue parent;
 
     public Issue() {
         super();
@@ -421,6 +428,24 @@
                 .ge("createdDate", JodaDateUtil.before(days)).order().desc("createdDate").findList();
     }
 
+    public static List<Issue> findByProject(Project project, String filter) {
+        ExpressionList<Issue> el = finder.where()
+                .eq("project.id", project.id);
+        if(StringUtils.isNotEmpty(filter)){
+            el.icontains("title", filter);
+        }
+        return el.order().desc("createdDate").findList();
+    }
+
+    public static List<Issue> findByProject(Project project, String filter, int limit) {
+        ExpressionList<Issue> el = finder.where()
+                .eq("project.id", project.id);
+        if(StringUtils.isNotEmpty(filter)){
+            el.icontains("title", filter);
+        }
+        return el.setMaxRows(10).order().desc("createdDate").findList();
+    }
+
     public static Page<Issue> findIssuesByState(int size, int pageNum, State state) {
         return finder.where().eq("state", state)
                 .order().desc("createdDate")
@@ -581,6 +606,31 @@
                 .findRowCount();
     }
 
+    public static List<Issue> findByParentIssueId(Long parentIssueId){
+        return finder.where()
+                .eq("parent.id", parentIssueId)
+                .findList();
+    }
 
+    public boolean hasChildIssue(){
+        List<Issue> issues = finder.where()
+                .eq("parent.id", this.id)
+                .setFirstRow(1)
+                .setMaxRows(1).findList();
+        return issues.size() > 0;
+    }
 
+    public static List<Issue> findByParentIssueIdAndState(Long parentIssueId, State state){
+        return finder.where()
+                .eq("parent.id", parentIssueId)
+                .eq("state", state)
+                .findList();
+    }
+
+    public static int countByParentIssueIdAndState(Long parentIssueId, State state){
+        return finder.where()
+                .eq("parent.id", parentIssueId)
+                .eq("state", state)
+                .findRowCount();
+    }
 }
app/models/Posting.java
--- app/models/Posting.java
+++ app/models/Posting.java
@@ -68,6 +68,9 @@
         return comments.size();
     }
 
+    @OneToOne
+    public Posting parent;
+
     public Posting() {
         super();
     }
app/models/User.java
--- app/models/User.java
+++ app/models/User.java
@@ -1,23 +1,9 @@
 /**
- * Yobi, Project Hosting SW
- *
- * Copyright 2012 NAVER Corp.
- * http://yobi.io
- *
- * @author Ahn Hyeok Jun
- *
- * 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.
- */
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp.
+ * https://yona.io
+ **/
 package models;
 
 import com.avaje.ebean.*;
app/views/common/select2.scala.html
--- app/views/common/select2.scala.html
+++ app/views/common/select2.scala.html
@@ -52,9 +52,14 @@
 </div>
 </script>
 <script id="tplSelect2ProjectsWithoutAvatar" type="text/x-jquery-tmpl">
-<div class="usf-group" title="${name}>
-    <span class="avatar-wrap smaller"> </span>
+<div class="usf-group" title="${name}">
+    <span class="width16px"></span>
     <span class="name">${name}</span>
 </div>
 </script>
 
+<script id="tplSelect2FormatIssues" type="text/x-jquery-tmpl">
+<div title="${name}">
+    ${name}
+</div>
+</script>
app/views/issue/create.scala.html
--- app/views/issue/create.scala.html
+++ app/views/issue/create.scala.html
@@ -5,6 +5,7 @@
 * https://yona.io
 **@
 @import play.data.Form
+@import org.apache.commons.lang3.StringUtils
 @(title:String, issueForm: Form[Issue], project:Project, issueTemplate:String = "")
 @import helper._
 @import scala.collection.mutable.Map
@@ -13,6 +14,10 @@
 @import utils.AccessControl._
 @import utils.TemplateHelper._
 @import controllers.UserApp
+
+@parentIssueId = @{
+    play.mvc.Http.Context.current().request().getQueryString("parentIssueId")
+}
 
 @projectLayout(Messages(title), project, utils.MenuType.ISSUE) {
 @projectMenu(project, utils.MenuType.ISSUE, "main-menu-only")
@@ -23,12 +28,9 @@
         <div class="row-fluid">
             <div class="span12">
                 <dl>
-                    <dt>
-                        <label for="title">@Messages("title")</label>
-                    </dt>
                     <dd>
                     	@defining(issueForm.errors().get("title")) { errors =>
-                        <input type="text" id="title" name="title" value="" class="text title zen-mode @if(errors != null) {error}" maxlength="250" tabindex="1">
+                        <input type="text" id="title" name="title" placeholder="@Messages("title")" value="" class="text title zen-mode @if(errors != null) {error}" maxlength="250" tabindex="1">
     	                    @if(errors != null) {
     							<div class="message">
     							@for(error <- errors) {
@@ -36,6 +38,30 @@
     							}
     							</div>
     						}
+                        <div class="subtitle-message">+ Sub task</div>
+                        <div class="subtask-wrap">
+                            <div class="span3">
+                                <select id="projects" name="targetProjectName" data-format="projects" data-placeholder="@Messages("organization.choose.projects")" data-toggle="select2" data-container-css-class="fullsize" >
+                                    <option value="@project.name" data-avatar-url="@urlToProjectLogo(project)">@project.name</option>
+                                @for(project <- UserApp.currentUser().myProjects("")) {
+                                    <option value="@project.name" data-avatar-url="@urlToProjectLogo(project)">@project.name</option>
+                                }
+                                </select>
+                            </div>
+                            <div class="span8">
+                                <select id="parentId" name="parentIssueId" data-format="issues" data-placeholder="@Messages("organization.choose.projects")" data-toggle="select2" data-container-css-class="fullsize" >
+                                    <option disabled selected value> -- select an issue -- </option>
+                                    @if(StringUtils.isNotEmpty(parentIssueId)){
+                                        @defining(Issue.finder.byId(Long.parseLong(parentIssueId))) { issue =>
+                                            <option value="@parentIssueId" selected>#@issue.getNumber. @issue.title</option>
+                                        }
+                                    }
+                                @for(issue <- Issue.findByProject(project, "", 1)) {
+                                    <option value="@issue.id" @if(issue.id+"" == parentIssueId){selected}>#@issue.getNumber. @issue.title</option>
+                                }
+                                </select>
+                            </div>
+                        </div>
     					}
                     </dd>
                 </dl>
@@ -60,7 +86,7 @@
                      --><a href="javascript:history.back();" class="ybtn">@Messages("button.cancel")</a>
                     </div>
                 </div>
-                <div class="span3 span-hard-wrap">
+                <div class="span3 span-hard-wrap right-menu">
                     @if(isProjectResourceCreatable(UserApp.currentUser(), project, ResourceType.ISSUE_ASSIGNEE)) {
                     <dl class="issue-option">
                          <dt>@Messages("issue.assignee")</dt>
app/views/issue/edit.scala.html
--- app/views/issue/edit.scala.html
+++ app/views/issue/edit.scala.html
@@ -1,24 +1,11 @@
 @**
-* Yobi, Project Hosting SW
+* Yona, 21st Century Project Hosting SW
 *
-* Copyright 2012 NAVER Corp.
-* http://yobi.io
-*
-* @author park3251
-*
-* 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.
+* Copyright Yona & Yobi Authors & NAVER Corp.
+* https://yona.io
 **@
-@(title:String, issueForm: play.data.Form[Issue], issue:Issue,  project:Project)
+@import play.data.Form
+@(title:String, issueForm: Form[Issue], issue:Issue,  project:Project)
 @import helper._
 @import scala.collection.mutable.Map
 @import models.enumeration.ResourceType
@@ -26,10 +13,16 @@
 @import models.enumeration._
 @import utils.AccessControl._
 @import utils.TemplateHelper._
+@parentIssueId = @{
+    if(issue.parent != null) {
+        issue.parent.id
+    } else {
+        null
+    }
+}
 
 @projectLayout(Messages(title), project, utils.MenuType.ISSUE) {
 @projectMenu(project, utils.MenuType.ISSUE, "main-menu-only")
-
 <div class="page-wrap-outer">
     <div class="project-page-wrap">
         <div class="content-wrap frm-wrap">
@@ -52,6 +45,7 @@
     							}
     							</div>
     						}
+                          @partial_select_subtask(project, issue, parentIssueId)
     					}
                     </dd>
                 </dl>
app/views/issue/list.scala.html
--- app/views/issue/list.scala.html
+++ app/views/issue/list.scala.html
@@ -1,25 +1,13 @@
 @**
-* Yobi, Project Hosting SW
+* Yona, 21st Century Project Hosting SW
 *
-* Copyright 2012 NAVER Corp.
-* http://yobi.io
-*
-* @author Tae
-*
-* 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.
+* Copyright Yona & Yobi Authors & NAVER Corp.
+* https://yona.io
 **@
-@(title: String, currentPage: com.avaje.ebean.Page[Issue], param:
-models.support.SearchCondition, project:Project)
+@import com.avaje.ebean.Page
+@import models.support.SearchCondition
+@(title: String, currentPage: Page[Issue], param:
+SearchCondition, project:Project)
 
 @projectLayout(Messages(title), project, utils.MenuType.ISSUE){
 @projectMenu(project, utils.MenuType.ISSUE, "main-menu-only")
app/views/issue/my_partial_list.scala.html
--- app/views/issue/my_partial_list.scala.html
+++ app/views/issue/my_partial_list.scala.html
@@ -45,6 +45,8 @@
                     @agoOrDateString(issue.createdDate)
                 </span>
 
+                @partial_list_subtask(project, issue)
+
                 <span class="infos-item project-name">
                 <a href="@routes.ProjectApp.project(project.owner,project.name)" class="title project" data-toggle="tooltip" data-placement="bottom" title="@Messages("project.name")">
                 @project.name
@@ -53,7 +55,7 @@
                 <span class="infos-item post-id">#@issue.getNumber</span>
 
                 @if(issue.milestone != null && project != null && project.menuSetting != null && project.menuSetting.milestone) {
-                <span class="infos-item mileston-tag">
+                <span class="mileston-tag">
                     <a href="@routes.MilestoneApp.milestone(project.owner, project.name, issue.milestone.id)" data-toggle="tooltip" data-placement="bottom" title="@Messages("milestone")">
                         @issue.milestone.title
                     </a>
app/views/issue/partial_list.scala.html
--- app/views/issue/partial_list.scala.html
+++ app/views/issue/partial_list.scala.html
@@ -1,29 +1,17 @@
 @**
-* Yobi, Project Hosting SW
+* Yona, 21st Century Project Hosting SW
 *
-* Copyright 2013 NAVER Corp.
-* http://yobi.io
-*
-* @author Suwon Chae
-*
-* 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.
+* Copyright Yona & Yobi Authors & NAVER Corp.
+* https://yona.io
 **@
-@(project:Project, issueList:Collection[Issue], searchCondition:models.support.SearchCondition, pageIndex:Int, totalPageCount:Int)
+@import models.support.SearchCondition
+@(project:Project, issueList:Collection[Issue], searchCondition:SearchCondition, pageIndex:Int, totalPageCount:Int)
 
 @import java.util
 @import utils.JodaDateUtil
 @import utils.TemplateHelper._
 @import utils.AccessControl._
+@import models.enumeration.State
 
 
 @issueLabels(issue:Issue) = {@for(label <- issue.labels.toList.sortBy(r => (r.category.name, r.name))) {@label.category.name,@label.id,@label.name|}}
@@ -68,8 +56,10 @@
                         @agoOrDateString(issue.createdDate)
                     </span>
 
+                    @partial_list_subtask(project, issue)
+
                     @if(project.menuSetting.milestone && issue.milestone != null) {
-                    <span class="infos-item mileston-tag">
+                    <span class="mileston-tag">
                         <a href="@routes.MilestoneApp.milestone(project.owner, project.name, issue.milestone.id)" data-toggle="tooltip" data-placement="bottom" title="@Messages("milestone")">
                             @issue.milestone.title
                         </a>
 
app/views/issue/partial_list_subtask.scala.html (added)
+++ app/views/issue/partial_list_subtask.scala.html
@@ -0,0 +1,31 @@
+@**
+* Yona, 21st Century Project Hosting SW
+*
+* Copyright Yona & Yobi Authors & NAVER Corp.
+* https://yona.io
+**@
+@(project:Project, issue:Issue)
+
+@import utils.TemplateHelper._
+@import models.enumeration.State
+
+
+@if(issue.hasChildIssue) {
+    @defining(Issue.countByParentIssueIdAndState(issue.id, State.OPEN)) { openIssueCount =>
+        @defining(Issue.countByParentIssueIdAndState(issue.id, State.CLOSED)) { closedIssueCount =>
+            @defining(getPercent(closedIssueCount.toDouble, openIssueCount + closedIssueCount)) { percentage =>
+                <div class="upload-progress">
+                    <div class="bar @if(percentage == 100) {done} else {grey}" style="width: @percentage%;" title="Subtask"></div>
+                </div>
+                <span class="completion-ratio @if(percentage == 100){txt-green}">@if(percentage != 100){@closedIssueCount/}@(closedIssueCount + openIssueCount)</span>
+            }
+        }
+    }
+}
+@if(issue.parent != null){
+    <span class="infos-item subtask">
+    @defining(Issue.finder.byId(issue.parent.id)) { parentIssue =>
+        <a href="@routes.IssueApp.issue(project.owner, project.name, parentIssue.getNumber)">#@parentIssue.getNumber @parentIssue.title.take(5).trim()@if(parentIssue.title.size > 5){...}</a>
+    }
+    </span>
+}
 
app/views/issue/partial_select_subtask.scala.html (added)
+++ app/views/issue/partial_select_subtask.scala.html
@@ -0,0 +1,34 @@
+@**
+* Yona, 21st Century Project Hosting SW
+*
+* Copyright Yona & Yobi Authors & NAVER Corp.
+* https://yona.io
+**@
+@(project:Project, issue:Issue, parentIssueId:Long)
+
+@import utils.TemplateHelper._
+
+<div class="subtitle-message">+ Sub task</div>
+<div class="subtask-wrap">
+    <div class="span3">
+        <select id="projects" name="targetProjectName" data-format="projects" data-placeholder="@Messages("organization.choose.projects")" data-toggle="select2" data-container-css-class="fullsize" >
+            <option value="@project.name" data-avatar-url="@urlToProjectLogo(project)">@project.name</option>
+            @for(project <- UserApp.currentUser().myProjects("")) {
+                <option value="@project.name" data-avatar-url="@urlToProjectLogo(project)">@project.name</option>
+            }
+        </select>
+    </div>
+    <div class="span8">
+        <select id="parentId" name="parentIssueId" data-format="issues" data-placeholder="@Messages("organization.choose.projects")" data-toggle="select2" data-container-css-class="fullsize" >
+            <option value="" selected> -- select an issue -- </option>
+            @if(Option(parentIssueId).isDefined){
+                @defining(Issue.finder.byId(parentIssueId)) { issue =>
+                    <option value="@issue.id" selected>#@issue.getNumber. @issue.title</option>
+                }
+            }
+            @for(issue <- Issue.findByProject(project, "", 1)) {
+                <option value="@issue.id" @if(issue.id == parentIssueId){selected}>#@issue.getNumber. @issue.title</option>
+            }
+        </select>
+    </div>
+</div>
app/views/issue/view.scala.html
--- app/views/issue/view.scala.html
+++ app/views/issue/view.scala.html
@@ -14,6 +14,7 @@
 @import utils.AccessControl._
 @import play.libs.Json.toJson
 @import utils.Markdown
+@import models.enumeration.State
 
 @getTitle(issue:Issue) = @{ "#" + issue.getNumber + " " + issue.title }
 
@@ -43,6 +44,53 @@
 }
 
 @titleForOGTag = @{getTitle(issue) + " |:| " + issue.body.substring(0, Math.min(issue.body.length, 200))}
+
+@parentIssueId = @{
+    if(issue.parent != null){
+        issue.parent.id
+    } else {
+        issue.id
+    }
+}
+
+@showChildIssues(parentIssueId: Long) = {
+@defining(Issue.findByParentIssueIdAndState(parentIssueId, State.OPEN)) { openChildIssues =>
+    @defining(Issue.findByParentIssueIdAndState(parentIssueId, State.CLOSED)) { closedChildIssues =>
+        @if(!openChildIssues.isEmpty || !closedChildIssues.isEmpty) {
+            <div class="child-issues">
+                @defining(Issue.finder.byId(parentIssueId)) { parentIssue =>
+                    <div class="issue-item parent-issue"><a href="@routes.IssueApp.issue(project.owner, project.name, parentIssue.getNumber)" class="@if(parentIssue.id == issue.id){bold}">#@parentIssue.getNumber @parentIssue.title @if(parentIssue.assignee != null) {- @parentIssue.assignee.user.name}</a>
+                        @defining(getPercent(closedChildIssues.size.toDouble, (openChildIssues.size + closedChildIssues.size).toDouble)) { percentage =>
+                        <div class="upload-progress">
+                            <div class="bar @if(percentage == 100){done} else {grey}" style="width: @percentage%;" title="Subtask"></div>
+                        </div>
+                        <span class="@if(percentage == 100){txt-green}">@if(percentage != 100){@closedChildIssues.size/}@(openChildIssues.size + closedChildIssues.size)</span>
+                        <span class="parent-issue-state @parentIssue.state.state">@Messages("issue.state." + parentIssue.state.state)</span>
+                    </div>
+                }
+                }
+                <hr style="margin: 0"/>
+                @for(childIssue <- openChildIssues) {
+                    <div class="issue-item @if(childIssue.id == issue.id){bold}">
+                        <span class="state-label open"></span>
+                        <a href="@routes.IssueApp.issue(project.owner, project.name, childIssue.getNumber)">
+                            <span class="item-name">@childIssue.title @if(childIssue.assignee != null) {- @childIssue.assignee.user.name}</span>
+                        </a>
+                    </div>
+                }
+                @for(childIssue <- closedChildIssues) {
+                    <div class="issue-item @if(childIssue.id == issue.id){bold}">
+                        <span class="state-label closed"><i class=" yobicon-checkmark"></i></span>
+                        <a href="@routes.IssueApp.issue(project.owner, project.name, childIssue.getNumber)">
+                            <span class="item-name">@childIssue.title @if(childIssue.assignee != null) {- @childIssue.assignee.user.name}</span>
+                        </a>
+                    </div>
+                }
+            </div>
+        }
+    }
+}
+}
 
 @conatinsCurrentUserInWatchers = @{issue.getWatchers(false).contains(UserApp.currentUser())}
 
@@ -155,6 +203,13 @@
                     }
                 </div>
                 <div class="watcher-list"></div>
+                <div class="subtasks">
+                @if(issue.parent == null) {
+                    @showChildIssues(issue.id)
+                } else {
+                    @showChildIssues(issue.parent.id)
+                }
+                </div>
                 @** Comment **@
                 <div id="comments" class="board-comment-wrap">
                     <div id="timeline">
@@ -176,7 +231,7 @@
                         <dl>
                             @if(project.menuSetting.issue) {
                                 <dd class="project-btn-item">
-                                    <a href="@routes.IssueApp.newIssueForm(project.owner, project.name)" class="ybtn ybtn-success" target="_blank">@Messages("button.newIssue")</a>
+                                    <a href="@routes.IssueApp.newIssueForm(project.owner, project.name)?parentIssueId=@parentIssueId" class="ybtn ybtn-success" target="_blank">@Messages("button.newIssue")</a>
                                 </dd>
                             }
                             <dt>@Messages("issue.assignee")</dt>
 
conf/evolutions/default/13.sql (added)
+++ conf/evolutions/default/13.sql
@@ -0,0 +1,17 @@
+# --- !Ups
+ALTER TABLE issue ADD COLUMN parent_id bigint;
+alter table issue add constraint fk_issue_parent_id_01 foreign key (parent_id) references issue (id) on delete set null;
+CREATE INDEX ix_issue_parent_id ON issue (parent_id);
+
+ALTER TABLE posting ADD COLUMN parent_id bigint;
+alter table posting add constraint fk_posting_parent_id_01 foreign key (parent_id) references posting (id) on delete set null;
+CREATE INDEX ix_posting_parent_id ON posting (parent_id);
+
+# --- !Downs
+
+
+ALTER TABLE issue DROP FOREIGN KEY fk_issue_parent_id_01;
+ALTER TABLE posting DROP FOREIGN KEY fk_posting_parent_id_01;
+ALTER TABLE issue DROP COLUMN parent_id;
+ALTER TABLE posting DROP COLUMN parent_id;
+
public/javascripts/common/yobi.ui.Select2.js
--- public/javascripts/common/yobi.ui.Select2.js
+++ public/javascripts/common/yobi.ui.Select2.js
@@ -61,6 +61,11 @@
                     });
                 }
             },
+            "issues": function(itemObject){
+                return $yobi.tmpl($("#tplSelect2FormatIssues").text(), {
+                    "name"     : itemObject.text
+                });
+            },
             "user": function(itemObject){
                 var itemElement = $(itemObject.element);
                 var avatarURL = itemElement.data("avatarUrl");
Add a comment
List