diff --git a/app/coffee/app.coffee b/app/coffee/app.coffee index 0c0ef7e5..9dc6de61 100644 --- a/app/coffee/app.coffee +++ b/app/coffee/app.coffee @@ -26,6 +26,7 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide) -> $routeProvider.when("/project/:pslug/backlog", {templateUrl: "/partials/backlog.html"}) $routeProvider.when("/project/:pslug/taskboard/:id", {templateUrl: "/partials/taskboard.html"}) $routeProvider.when("/project/:pslug/search", {templateUrl: "/partials/search.html"}) + $routeProvider.when("/project/:pslug/kanban", {templateUrl: "/partials/kanban.html"}) # User stories $routeProvider.when("/project/:pslug/us/:usref", @@ -121,6 +122,7 @@ modules = [ # Specific Modules "taigaBacklog", "taigaTaskboard", + "taigaKanban" "taigaIssues", "taigaUserStories", "taigaTasks", diff --git a/app/coffee/modules/base.coffee b/app/coffee/modules/base.coffee index e05a7a1f..d6cc4440 100644 --- a/app/coffee/modules/base.coffee +++ b/app/coffee/modules/base.coffee @@ -54,6 +54,7 @@ urls = { "project": "/project/:project", "project-backlog": "/project/:project/backlog", "project-taskboard": "/project/:project/taskboard/:sprint", + "project-kanban": "/project/:project/kanban", "project-issues": "/project/:project/issues", "project-search": "/project/:project/search", "project-issues-detail": "/project/:project/issues/:ref", diff --git a/app/coffee/modules/kanban.coffee b/app/coffee/modules/kanban.coffee new file mode 100644 index 00000000..0e6a22ad --- /dev/null +++ b/app/coffee/modules/kanban.coffee @@ -0,0 +1,22 @@ +### +# 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/kanban.coffee +### + +module = angular.module("taigaKanban", []) diff --git a/app/coffee/modules/kanban/main.coffee b/app/coffee/modules/kanban/main.coffee new file mode 100644 index 00000000..4dc534f5 --- /dev/null +++ b/app/coffee/modules/kanban/main.coffee @@ -0,0 +1,273 @@ +### +# 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/kanban/main.coffee +### + +taiga = @.taiga + +mixOf = @.taiga.mixOf +toggleText = @.taiga.toggleText +scopeDefer = @.taiga.scopeDefer +bindOnce = @.taiga.bindOnce +groupBy = @.taiga.groupBy + +module = angular.module("taigaKanban") + +############################################################################# +## Kanban Controller +############################################################################# + +class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin) + @.$inject = [ + "$scope", + "$rootScope", + "$tgRepo", + "$tgConfirm", + "$tgResources", + "$routeParams", + "$q", + "$tgLocation" + ] + + constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @location) -> + _.bindAll(@) + + @scope.sectionName = "Kanban" + + promise = @.loadInitialData() + promise.then null, => + console.log "FAIL" + + # @scope.$on("usform:bulk:success", @.loadUserstories) + # @scope.$on("sprintform:create:success", @.loadSprints) + # @scope.$on("sprintform:create:success", @.loadProjectStats) + # @scope.$on("sprintform:remove:success", @.loadSprints) + # @scope.$on("sprintform:remove:success", @.loadProjectStats) + # @scope.$on("usform:new:success", @.loadUserstories) + # @scope.$on("usform:edit:success", @.loadUserstories) + # @scope.$on("sprint:us:move", @.moveUs) + # @scope.$on("sprint:us:moved", @.loadSprints) + # @scope.$on("sprint:us:moved", @.loadProjectStats) + + loadProjectStats: -> + return @rs.projects.stats(@scope.projectId).then (stats) => + @scope.stats = stats + completedPercentage = Math.round(100 * stats.closed_points / stats.total_points) + @scope.stats.completedPercentage = "#{completedPercentage}%" + return stats + + loadUserstories: -> + return @rs.userstories.listUnassigned(@scope.projectId).then (userstories) => + @scope.userstories = userstories + + @scope.usByStatus = _.groupBy(userstories, "status") + + for status in @scope.usStatusList + if not @scope.usByStatus[status.id]? + @scope.usByStatus[status.id] = [] + + # The broadcast must be executed when the DOM has been fully reloaded. + # We can't assure when this exactly happens so we need a defer + scopeDefer @scope, => + @scope.$broadcast("userstories:loaded") + + return userstories + + loadKanban: -> + return @q.all([ + @.loadProjectStats(), + @.loadUserstories() + ]) + + loadProject: -> + return @rs.projects.get(@scope.projectId).then (project) => + @scope.project = project + @scope.points = _.sortBy(project.points, "order") + @scope.pointsById = groupBy(project.points, (x) -> x.id) + @scope.usStatusById = groupBy(project.us_statuses, (x) -> x.id) + @scope.usStatusList = _.sortBy(project.us_statuses, "id") + return project + + loadInitialData: -> + # Resolve project slug + promise = @repo.resolve({pslug: @params.pslug}).then (data) => + @scope.projectId = data.project + return data + + return promise.then(=> @.loadProject()) + .then(=> @.loadUsersAndRoles()) + .then(=> @.loadKanban()) + + prepareBulkUpdateData: (uses) -> + return _.map(uses, (x) -> [x.id, x.order]) + + resortUserStories: (uses) -> + items = [] + for item, index in uses + item.order = index + if item.isModified() + items.push(item) + + return items + + moveUs: (ctx, us, newUsIndex, newSprintId) -> + oldSprintId = us.milestone + + # In the same sprint or in the backlog + if newSprintId == oldSprintId + items = null + userstories = null + + if newSprintId == null + userstories = @scope.userstories + else + userstories = @scope.sprintsById[newSprintId].user_stories + + @scope.$apply -> + r = userstories.indexOf(us) + userstories.splice(r, 1) + userstories.splice(newUsIndex, 0, us) + + # Rehash userstories order field + items = @.resortUserStories(userstories) + data = @.prepareBulkUpdateData(items) + + # Persist in bulk all affected + # userstories with order change + promise = @rs.userstories.bulkUpdateOrder(us.project, data).then => + @rootscope.$broadcast("sprint:us:moved", us, oldSprintId, newSprintId) + + promise.then null, -> + console.log "FAIL" + + return promise + + # From sprint to backlog + if newSprintId == null + us.milestone = null + + @scope.$apply => + # Add new us to backlog userstories list + @scope.userstories.splice(newUsIndex, 0, us) + @scope.visibleUserstories.splice(newUsIndex, 0, us) + + # Execute the prefiltering of user stories + @.filterVisibleUserstories() + + # Remove the us from the sprint list. + sprint = @scope.sprintsById[oldSprintId] + r = sprint.user_stories.indexOf(us) + sprint.user_stories.splice(r, 1) + + # Persist the milestone change of userstory + promise = @repo.save(us) + + # Rehash userstories order field + # and persist in bulk all changes. + promise = promise.then => + items = @.resortUserStories(@scope.userstories) + data = @.prepareBulkUpdateData(items) + promise = @rs.userstories.bulkUpdateOrder(us.project, data).then => + @rootscope.$broadcast("sprint:us:moved", us, oldSprintId, newSprintId) + + promise.then null, -> + # TODO + console.log "FAIL" + + return promise + + # From backlog to sprint + newSprint = @scope.sprintsById[newSprintId] + if us.milestone == null + us.milestone = newSprintId + + @scope.$apply => + # Add moving us to sprint user stories list + newSprint.user_stories.splice(newUsIndex, 0, us) + + # Remove moving us from backlog userstories lists. + r = @scope.visibleUserstories.indexOf(us) + @scope.visibleUserstories.splice(r, 1) + r = @scope.userstories.indexOf(us) + @scope.userstories.splice(r, 1) + + # From sprint to sprint + else + us.milestone = newSprintId + + @scope.$apply => + # Add new us to backlog userstories list + newSprint.user_stories.splice(newUsIndex, 0, us) + + # Remove the us from the sprint list. + oldSprint = @scope.sprintsById[oldSprintId] + r = oldSprint.user_stories.indexOf(us) + oldSprint.user_stories.splice(r, 1) + + # Persist the milestone change of userstory + promise = @repo.save(us) + + # Rehash userstories order field + # and persist in bulk all changes. + promise = promise.then => + items = @.resortUserStories(newSprint.user_stories) + data = @.prepareBulkUpdateData(items) + promise = @rs.userstories.bulkUpdateOrder(us.project, data).then => + @rootscope.$broadcast("sprint:us:moved", us, oldSprintId, newSprintId) + + promise.then null, -> + # TODO + console.log "FAIL" + + return promise + + ## Template actions + # editUserStory: (us) -> + # @rootscope.$broadcast("usform:edit", us) + + # deleteUserStory: (us) -> + # #TODO: i18n + # title = "Delete User Story" + # subtitle = us.subject + + # @confirm.ask(title, subtitle).then => + # # We modify the userstories in scope so the user doesn't see the removed US for a while + # @scope.userstories = _.without(@scope.userstories, us); + # @filterVisibleUserstories() + # @.repo.remove(us).then => + # @.loadBacklog() + + # addNewUs: (type) -> + # switch type + # when "standard" then @rootscope.$broadcast("usform:new") + # when "bulk" then @rootscope.$broadcast("usform:bulk") + + +module.controller("KanbanController", KanbanController) + +############################################################################# +## Kanban Directive +############################################################################# + +KanbanDirective = ($repo, $rootscope) -> + link = ($scope, $el, $attrs) -> + return {link: link} + + +module.directive("tgKanban", ["$tgRepo", "$rootScope", KanbanDirective]) diff --git a/app/coffee/modules/kanban/sortable.coffee b/app/coffee/modules/kanban/sortable.coffee new file mode 100644 index 00000000..b11b4d11 --- /dev/null +++ b/app/coffee/modules/kanban/sortable.coffee @@ -0,0 +1,94 @@ +### +# 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/kanban/sortable.coffee +### + +taiga = @.taiga + +mixOf = @.taiga.mixOf +toggleText = @.taiga.toggleText +scopeDefer = @.taiga.scopeDefer +bindOnce = @.taiga.bindOnce +groupBy = @.taiga.groupBy + +module = angular.module("taigaKanban") + + +############################################################################# +## Sortable Directive +############################################################################# + +KanbanSortableDirective = ($repo, $rs, $rootscope) -> + + ######################### + ## Drag & Drop Link + ######################### + + link = ($scope, $el, $attrs) -> + oldParentScope = null + newParentScope = null + itemEl = null + tdom = $el + + deleteElement = (itemEl) -> + # Completelly remove item and its scope from dom + itemEl.scope().$destroy() + itemEl.off() + itemEl.remove() + + tdom.sortable({ + handle: ".icon-drag-h", + dropOnEmpty: true + connectWith: ".taskboard_task-playground" + revert: 400 + }) + + tdom.on "sortstop", (event, ui) -> + # parentEl = ui.item.parent() + # itemEl = ui.item + # itemTask = itemEl.scope().task + # itemIndex = itemEl.index() + # newParentScope = parentEl.scope() + + # oldUsId = if oldParentScope.us then oldParentScope.us.id else null + # oldStatusId = oldParentScope.st.id + # newUsId = if newParentScope.us then newParentScope.us.id else null + # newStatusId = newParentScope.st.id + + # if newStatusId != oldStatusId or newUsId != oldUsId + # deleteElement(itemEl) + + # $scope.$apply -> + # $rootscope.$broadcast("taskboard:task:move", itemTask, newUsId, newStatusId, itemIndex) + + tdom.on "sortstart", (event, ui) -> + oldParentScope = ui.item.parent().scope() + + $scope.$on "$destroy", -> + $el.off() + + return {link: link} + + +module.directive("tgKanbanSortable", [ + "$tgRepo", + "$tgResources", + "$rootScope", + KanbanSortableDirective +]) diff --git a/app/coffee/modules/nav.coffee b/app/coffee/modules/nav.coffee index 9820117b..735194e7 100644 --- a/app/coffee/modules/nav.coffee +++ b/app/coffee/modules/nav.coffee @@ -82,7 +82,7 @@ ProjectMenuDirective = ($log, $compile, $rootscope) -> diff --git a/app/partials/kanban.jade b/app/partials/kanban.jade index fe4606ca..92b47194 100644 --- a/app/partials/kanban.jade +++ b/app/partials/kanban.jade @@ -1,23 +1,23 @@ -extends layout +extends dummy-layout block head title Taiga Project management web application with scrum in mind! block content - div.wrapper + div.wrapper(tg-kanban, ng-controller="KanbanController as ctrl") section.main.kanban div.kanban-detail-header h1 - span ProjectName - span.green Sprint Name - span.date 02/10/2014-15/10/2014 + span(tg-bo-html="project.name") + // span.green Sprint Name + // span.date 02/10/2014-15/10/2014 div.kanban-settings - a.button.button-trans(href="", title="Filter") - span.icon.icon-filter - span Filters + // a.button.button-trans(href="", title="Filter") + // span.icon.icon-filter + // span Filters a.button.button-gray(href="", title="Filter") span Show Statistics //-include views/components/large-summary - include views/modules/burndown + //-include views/modules/burndown //-include views/modules/list-filters-kanban include views/modules/kanban-table diff --git a/app/partials/views/components/kanban-task.jade b/app/partials/views/components/kanban-task.jade index 537e9bf8..8a121b1f 100644 --- a/app/partials/views/components/kanban-task.jade +++ b/app/partials/views/components/kanban-task.jade @@ -12,4 +12,4 @@ div.kanban-task-inner a(href="", title="Change assignation") Username a.icon.icon-edit(href="", title="Edit", ng-click="ctrl.editTask(task)") a.icon.icon-drag-h(href="", title="Drag&Drop") - a.task-points(href="", title="task points") 8 + a.task-points(href="", title="task points", tg-bo-html="us.total_points") -- diff --git a/app/partials/views/modules/kanban-table.jade b/app/partials/views/modules/kanban-table.jade index 1ccf9c57..dc25ce87 100644 --- a/app/partials/views/modules/kanban-table.jade +++ b/app/partials/views/modules/kanban-table.jade @@ -1,51 +1,13 @@ div.kanban-table div.kanban-table-header div.kanban-table-inner - h2.task-colum_name - span Task - a.icon.icon-plus(href="", title="Add New task") - h2.task-colum_name - span Open - a.icon.icon-plus(href="", title="Add New task") - h2.task-colum_name - span Ready for test - a.icon.icon-plus(href="", title="Add New task") - h2.task-colum_name - span Closed - a.icon.icon-plus(href="", title="Add New task") - h2.task-colum_name - span Task - a.icon.icon-plus(href="", title="Add New task") - h2.task-colum_name - span Open - a.icon.icon-plus(href="", title="Add New task") - h2.task-colum_name - span Ready for test - a.icon.icon-plus(href="", title="Add New task") - h2.task-colum_name - span Closed + h2.task-colum_name(ng-repeat="s in usStatusList track by s.id") + span(tg-bo-html="s.name") a.icon.icon-plus(href="", title="Add New task") + div.kanban-table-body div.kanban-table-inner - div.taskboard_task-playground.task-column - div.kanban-task - include ../components/kanban-task - div.taskboard_task-playground.task-column - div.kanban-task - include ../components/kanban-task - div.taskboard_task-playground.task-column - - div.taskboard_task-playground.task-column - div.kanban-task - include ../components/kanban-task - div.taskboard_task-playground.task-column - div.kanban-task - include ../components/kanban-task - div.taskboard_task-playground.task-column - div.kanban-task - include ../components/kanban-task - div.taskboard_task-playground.task-column - - div.taskboard_task-playground.task-column - div.kanban-task + div.taskboard_task-playground.task-column(ng-repeat="s in usStatusList track by s.id", + tg-kanban-sortable) + div.kanban-task(ng-repeat="us in usByStatus[s.id] track by us.id") include ../components/kanban-task diff --git a/gulpfile.coffee b/gulpfile.coffee index bd7479e0..f3879877 100644 --- a/gulpfile.coffee +++ b/gulpfile.coffee @@ -42,6 +42,7 @@ paths = { "app/coffee/modules/common/*.coffee", "app/coffee/modules/backlog/*.coffee", "app/coffee/modules/taskboard/*.coffee", + "app/coffee/modules/kanban/*.coffee", "app/coffee/modules/issues/*.coffee", "app/coffee/modules/userstories/*.coffee", "app/coffee/modules/tasks/*.coffee",