From e3890c6d5d9ba64c550bb63b0840dd1c37dbea70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 9 Sep 2014 18:00:26 +0200 Subject: [PATCH] Add powerful related tasks management on user story detail --- app/coffee/app.coffee | 1 + app/coffee/modules/common/popovers.coffee | 77 ++++ app/coffee/modules/related-tasks.coffee | 342 ++++++++++++++++++ app/coffee/modules/userstories/detail.coffee | 12 +- app/partials/us-detail.jade | 2 + app/partials/views/modules/related-tasks.jade | 18 +- app/styles/modules/common/related-tasks.scss | 194 ++++++++-- 7 files changed, 611 insertions(+), 35 deletions(-) create mode 100644 app/coffee/modules/related-tasks.coffee diff --git a/app/coffee/app.coffee b/app/coffee/app.coffee index 7a7fad73..e3cc9698 100644 --- a/app/coffee/app.coffee +++ b/app/coffee/app.coffee @@ -182,6 +182,7 @@ modules = [ "taigaAuth", # Specific Modules + "taigaRelatedTasks", "taigaBacklog", "taigaTaskboard", "taigaKanban" diff --git a/app/coffee/modules/common/popovers.coffee b/app/coffee/modules/common/popovers.coffee index 55b85586..4abf6c95 100644 --- a/app/coffee/modules/common/popovers.coffee +++ b/app/coffee/modules/common/popovers.coffee @@ -101,6 +101,83 @@ UsStatusDirective = ($repo, popoverService) -> module.directive("tgUsStatus", ["$tgRepo", UsStatusDirective]) +RelatedTaskStatusDirective = ($repo, popoverService) -> + ### + Print the status of a related task and a popover to change it. + - tg-related-task-status: The related task + - on-update: Method call after US is updated + + Example: + + div.status(tg-related-task-status="task" on-update="ctrl.loadSprintState()") + a.task-status(href="", title="Status Name") + + NOTE: This directive need 'taskStatusById' and 'project'. + ### + selectionTemplate = _.template(""" + """) + + updateTaskStatus = ($el, task, taskStatusById) -> + taskStatusDomParent = $el.find(".us-status") + taskStatusDom = $el.find(".task-status .task-status-bind") + + if taskStatusById[task.status] + taskStatusDom.text(taskStatusById[task.status].name) + taskStatusDomParent.css('color', taskStatusById[task.status].color) + + link = ($scope, $el, $attrs) -> + $ctrl = $el.controller() + task = $scope.$eval($attrs.tgRelatedTaskStatus) + notAutoSave = $scope.$eval($attrs.notAutoSave) + autoSave = !notAutoSave + + $el.on "click", ".task-status", (event) -> + event.preventDefault() + event.stopPropagation() + + $el.find(".pop-status").popover().open() + + # pop = $el.find(".pop-status") + # popoverService.open(pop) + + $el.on "click", ".status", (event) -> + event.preventDefault() + event.stopPropagation() + target = angular.element(event.currentTarget) + task.status = target.data("status-id") + $el.find(".pop-status").popover().close() + updateTaskStatus($el, task, $scope.taskStatusById) + + if autoSave + $scope.$apply () -> + $repo.save(task).then -> + $scope.$eval($attrs.onUpdate) + $scope.$emit("related-tasks:status-changed") + + taiga.bindOnce $scope, "project", (project) -> + $el.append(selectionTemplate({ 'statuses': project.task_statuses })) + updateTaskStatus($el, task, $scope.taskStatusById) + + # If the user has not enough permissions the click events are unbinded + if project.my_permissions.indexOf("modify_task") == -1 + $el.unbind("click") + $el.find("a").addClass("not-clickable") + + $scope.$on "$destroy", -> + $el.off() + + return {link: link} + +module.directive("tgRelatedTaskStatus", ["$tgRepo", RelatedTaskStatusDirective]) + $.fn.popover = () -> $el = @ diff --git a/app/coffee/modules/related-tasks.coffee b/app/coffee/modules/related-tasks.coffee new file mode 100644 index 00000000..38d5e57e --- /dev/null +++ b/app/coffee/modules/related-tasks.coffee @@ -0,0 +1,342 @@ +### +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino Garcia +# Copyright (C) 2014 David Barragán Merino +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: modules/related-tasks.coffee +### + +taiga = @.taiga +trim = @.taiga.trim + +module = angular.module("taigaRelatedTasks", []) + +RelatedTaskRowDirective = ($repo, $compile, $confirm, $rootscope) -> + templateView = _.template(""" +
+
+ + + #<%- task.ref %> + <%- task.subject %> + +
+ <% if(perms.modify_task) { %> + + <% } %> + <% if(perms.delete_task) { %> + + <% } %> +
+
+
+ +
+
+
+ <% if(perms.modify_task) { %> + + <% } %> +
+
+ """) + + templateEdit = _.template(""" +
+
+ +
+ + +
+
+
+ +
+
+
+ +
+
+ """) + + link = ($scope, $el, $attrs, $model) -> + saveTask = (task) -> + task.subject = $el.find('input').val() + promise = $repo.save(task) + promise.then => + $confirm.notify("success") + $rootscope.$broadcast("related-tasks:update") + + promise.then null, => + $confirm.notify("error") + + renderEdit = (task) -> + $el.html($compile(templateEdit({task: task}))($scope)) + + $el.on "keyup", "input", (event) -> + if event.keyCode == 13 + saveTask($model.$modelValue) + renderView($model.$modelValue) + else if event.keyCode == 27 + renderView($model.$modelValue) + + $el.on "click", ".icon-floppy", (event) -> + saveTask($model.$modelValue) + renderView($model.$modelValue) + + $el.on "click", ".cancel-edit", (event) -> + renderView($model.$modelValue) + + renderView = (task) -> + $el.off() + + perms = { + modify_task: $scope.project.my_permissions.indexOf("modify_task") != -1 + delete_task: $scope.project.my_permissions.indexOf("delete_task") != -1 + } + + $el.html($compile(templateView({task: task, perms: perms}))($scope)) + + $el.on "click", ".icon-edit", -> + renderEdit($model.$modelValue) + $el.find('input').focus().select() + + $el.on "click", ".delete-task", (event) -> + #TODO: i18n + task = $model.$modelValue + title = "Delete Task" + subtitle = task.subject + + $confirm.ask(title, subtitle).then -> + $repo.remove(task).then -> + $confirm.notify("success") + $scope.$emit("related-tasks:delete") + + $scope.$watch $attrs.ngModel, (val) -> + return if not val + renderView(val) + + $scope.$on "related-tasks:assigned-to-changed", -> + $rootscope.$broadcast("related-tasks:update") + + $scope.$on "related-tasks:status-changed", -> + $rootscope.$broadcast("related-tasks:update") + + $scope.$on "$destroy", -> + $el.off() + + return {link:link, require:"ngModel"} + +module.directive("tgRelatedTaskRow", ["$tgRepo", "$compile", "$tgConfirm", "$rootScope", RelatedTaskRowDirective]) + +RelatedTaskCreateFormDirective = ($repo, $compile, $confirm, $tgmodel) -> + template = _.template(""" +
+
+ +
+ + +
+
+
+ +
+
+
+ +
+
+ """) + + newTask = { + subject: "" + assigned_to: null + } + + link = ($scope, $el, $attrs) -> + createTask = (task) -> + task.subject = $el.find('input').val() + task.assigned_to = $scope.newTask.assigned_to + task.status = $scope.newTask.status + $scope.newTask.status = $scope.project.default_task_status + $scope.newTask.assigned_to = null + + promise = $repo.create("tasks", task) + promise.then -> + $scope.$emit("related-tasks:add") + $confirm.notify("success") + + promise.then null, -> + $confirm.notify("error") + + return promise + + render = -> + $el.off() + + $el.html($compile(template())($scope)) + $el.find('input').focus().select() + + $el.on "keyup", "input", (event)-> + if event.keyCode == 13 + createTask(newTask).then -> + render() + else if event.keyCode == 27 + $el.html("") + + $el.on "click", ".icon-delete", (event)-> + $el.html("") + + $el.on "click", ".icon-floppy", (event)-> + createTask(newTask).then -> + $el.html("") + + $scope.$watch "us", (val) -> + return if not val + newTask["status"] = $scope.project.default_task_status + newTask["project"] = $scope.project.id + newTask["user_story"] = $scope.us.id + $scope.newTask = $tgmodel.make_model("tasks", newTask) + $el.html("") + + $scope.$on "related-tasks:show-form", -> + render() + + $scope.$on "$destroy", -> + $el.off() + + return {link: link} +module.directive("tgRelatedTaskCreateForm", ["$tgRepo", "$compile", "$tgConfirm", "$tgModel", RelatedTaskCreateFormDirective]) + +RelatedTaskCreateButtonDirective = ($repo, $compile, $confirm, $tgmodel) -> + template = _.template(""" + + """) + + link = ($scope, $el, $attrs) -> + $scope.$watch "project", (val) -> + return if not val + $el.off() + if $scope.project.my_permissions.indexOf("add_task") != -1 + $el.html(template()) + else + $el.html("") + + $el.on "click", ".button", (event)-> + $scope.$emit("related-tasks:add-new-clicked") + + $scope.$on "$destroy", -> + $el.off() + + return {link: link} +module.directive("tgRelatedTaskCreateButton", ["$tgRepo", "$compile", "$tgConfirm", "$tgModel", RelatedTaskCreateButtonDirective]) + +RelatedTasksDirective = ($repo, $rs, $rootscope) -> + link = ($scope, $el, $attrs) -> + loadTasks = -> + return $rs.tasks.list($scope.projectId, null, $scope.usId).then (tasks) => + $scope.tasks = tasks + return tasks + + $scope.$on "related-tasks:add", -> + loadTasks().then -> + $rootscope.$broadcast("related-tasks:update") + + $scope.$on "related-tasks:delete", -> + loadTasks().then -> + $rootscope.$broadcast("related-tasks:update") + + $scope.$on "related-tasks:add-new-clicked", -> + $scope.$broadcast("related-tasks:show-form") + + $scope.$watch "us", (val) -> + return if not val + loadTasks() + + $scope.$on "$destroy", -> + $el.off() + + return {link: link} +module.directive("tgRelatedTasks", ["$tgRepo", "$tgResources", "$rootScope", RelatedTasksDirective]) + +RelatedTaskAssignedToInlineEditionDirective = ($repo, $rootscope, popoverService) -> + template = _.template(""" + <%- name %> +
<%- name %>
+ """) + + link = ($scope, $el, $attrs) -> + updateRelatedTask = (task) -> + ctx = {name: "Unassigned", imgurl: "/images/unnamed.png"} + member = $scope.usersById[task.assigned_to] + if member + ctx.imgurl = member.photo + ctx.name = member.full_name_display + + $el.find(".avatar").html(template(ctx)) + $el.find(".task-assignedto").attr('title', ctx.name) + + $ctrl = $el.controller() + task = $scope.$eval($attrs.tgRelatedTaskAssignedToInlineEdition) + notAutoSave = $scope.$eval($attrs.notAutoSave) + autoSave = !notAutoSave + + updateRelatedTask(task) + + $el.on "click", ".task-assignedto", (event) -> + $rootscope.$broadcast("assigned-to:add", task) + + taiga.bindOnce $scope, "project", (project) -> + # If the user has not enough permissions the click events are unbinded + if project.my_permissions.indexOf("modify_task") == -1 + $el.unbind("click") + $el.find("a").addClass("not-clickable") + + $scope.$on "assigned-to:added", (ctx, userId, updatedRelatedTask) => + if updatedRelatedTask.id == task.id + updatedRelatedTask.assigned_to = userId + if autoSave + $repo.save(updatedRelatedTask).then -> + $scope.$emit("related-tasks:assigned-to-changed") + updateRelatedTask(updatedRelatedTask) + + $scope.$on "$destroy", -> + $el.off() + + return {link: link} + +module.directive("tgRelatedTaskAssignedToInlineEdition", ["$tgRepo", "$rootScope", RelatedTaskAssignedToInlineEditionDirective]) diff --git a/app/coffee/modules/userstories/detail.coffee b/app/coffee/modules/userstories/detail.coffee index a4f6c344..f2e1b618 100644 --- a/app/coffee/modules/userstories/detail.coffee +++ b/app/coffee/modules/userstories/detail.coffee @@ -101,11 +101,6 @@ class UserStoryDetailController extends mixOf(taiga.Controller, taiga.PageMixin, return us - loadTasks: -> - return @rs.tasks.list(@scope.projectId, null, @scope.usId).then (tasks) => - @scope.tasks = tasks - return tasks - loadInitialData: -> params = { pslug: @params.pslug @@ -120,8 +115,8 @@ class UserStoryDetailController extends mixOf(taiga.Controller, taiga.PageMixin, return promise.then(=> @.loadProject()) .then(=> @.loadUsersAndRoles()) .then(=> @q.all([@.loadUs(), - @.loadTasks(), @.loadAttachments(@scope.usId)])) + block: -> @rootscope.$broadcast("block", @scope.us) @@ -311,6 +306,11 @@ UsStatusDetailDirective = () -> if us? renderUsstatus(us) + $scope.$on "related-tasks:update", -> + us = $scope.$eval $attrs.ngModel + if us? + renderUsstatus(us) + if editable $el.on "click", ".status-data", (event) -> event.preventDefault() diff --git a/app/partials/us-detail.jade b/app/partials/us-detail.jade index 812975a5..628fc529 100644 --- a/app/partials/us-detail.jade +++ b/app/partials/us-detail.jade @@ -49,3 +49,5 @@ block content ng-class="{'active': us.client_requirement}") Client requirement span.button.button-gray(href="", title="Team requirement", ng-class="{'active': us.team_requirement}") Team requirement + + div.lightbox.lightbox-select-user.hidden(tg-lb-assignedto) diff --git a/app/partials/views/modules/related-tasks.jade b/app/partials/views/modules/related-tasks.jade index 5dbf3af9..29ee64fb 100644 --- a/app/partials/views/modules/related-tasks.jade +++ b/app/partials/views/modules/related-tasks.jade @@ -1,7 +1,11 @@ -section.related-tasks(ng-show="tasks") - h2 Related Tasks - ul.task-list - li.single-related-task(ng-repeat="task in tasks", ng-class="{closed: task.is_closed, blocked: task.is_blocked, iocaine: task.is_iocaine}") - span.icon.icon-iocaine(ng-show="task.is_iocaine") - a(href="", tg-bo-title="task.subject", tg-bo-bind="task.subject" tg-nav="project-tasks-detail:project=project.slug,ref=task.ref") - span.blocked-text(ng-show="task.is_blocked") (Blocked) +section.related-tasks(tg-related-tasks) + h2 Related tasks + div(tg-related-task-create-button) + div.related-tasks-header + .row.related-tasks-title + .tasks Task Name + .status Status + .assigned-to Assigned to + div.related-tasks-body + div.row.single-related-task(ng-repeat="task in tasks", ng-class="{closed: task.is_closed, blocked: task.is_blocked, iocaine: task.is_iocaine}", tg-related-task-row, ng-model="task") + div.row.single-related-task(tg-related-task-create-form) diff --git a/app/styles/modules/common/related-tasks.scss b/app/styles/modules/common/related-tasks.scss index da3750ac..42d679fe 100644 --- a/app/styles/modules/common/related-tasks.scss +++ b/app/styles/modules/common/related-tasks.scss @@ -1,38 +1,188 @@ .related-tasks { - ul { - list-style: disc inside; + margin-bottom: 2rem; + position: relative; +} + +.related-tasks-header, +.related-tasks-body { + width: 100%; + .row { + @extend %small; + @include table-flex(center, center, flex, row, wrap, center); + border-bottom: 1px solid $gray-light; + padding: .5rem 0 .5rem .5rem; + text-align: left; + width: 100%; } - li { - margin-bottom: .5rem; - &.iocaine { - list-style: none inside; + .row { + &:hover { + background: transparent; } - &.blocked { - color: $red; - text-decoration: line-through; + .tasks { + @include table-flex-child(10, 78%, 0); + } + .status { + @include table-flex-child(0, 10%, 0); + } + .assigned-to { + @include table-flex-child(0, 10%, 0); + } + + } + .status { + position: relative; + text-align: left; + .popover { a { - color: $red; + text-align: left; + width: 100%; + } + .point { + text-align: center; } } - &.closed { + .icon { color: $gray-light; - text-decoration: line-through; - a, - .icon-iocaine { - color: $gray-light; + margin-left: .2rem; + } + } + .pop-status { + @include popover(200px, 0, 40%, '', ''); + padding-right: 1rem; + &.fix { + bottom: 0; + top: auto; + } + } +} + +.related-tasks-header { + .related-tasks-title { + @extend %medium; + @extend %bold; + border-bottom: 2px solid $gray-light; + margin-top: 1rem; + } +} + +.related-tasks-body { + .row { + position: relative; + &:hover { + .task-settings { + @include transition (all .2s ease-in); + opacity: 1; } } + &:last-child { + border-bottom: 0; + } + } + .task-name { + position: relative; a { - color: $grayer; + display: inline-block; + max-width: 90%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + input { + margin-right: 1rem; + padding: 3px; + width: 85%; + } + } + .blocked, + .blocked:hover { + background: $red-light; + color: $white; + a { + color: $white !important; + &:hover { + color: $white; + } + } + .icon { + color: $white; + &:hover { + color: $white; + } } } .icon-iocaine { - color: $green-taiga; - margin-right: .3rem; - position: relative; - right: .3rem; + display: none; } - .blocked-text { - margin-left: .3rem; + .iocaine, + .iocaine:hover { + background: rgba($fresh-taiga, .3); + .icon-iocaine { + @extend %large; + display: inline-block; + margin-right: .5rem; + vertical-align: top; + } + } + .task-settings { + margin: 0 0 0 2rem; + opacity: 0; + position: absolute; + right: 0; + top: .1rem; + width: 10%; + a { + @include transition (all .2s ease-in); + @extend %large; + color: $gray-light; + &:hover { + @include transition (all .2s ease-in); + color: $grayer; + } + } + } + .assigned-to { + position: relative; + text-align: left; + } + .task-assignedto { + cursor: pointer; + position: relative; + &:hover { + .icon { + @include transition(opacity .3s linear); + opacity: 1; + } + } + figcaption { + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .icon { + @include transition(opacity .3s linear); + opacity: 0; + position: absolute; + right: .5rem; + top: .5rem; + } + } + .avatar { + align-items: center; + display: flex; + img { + flex-basis: 35px; + } + figcaption { + margin-left: .5rem; + } + } +} +.related-tasks-buttons { + position: absolute; + right: 0; + top: 0; + .button { + cursor: pointer; } }