From e6e4dd35e779b2d033ddab1fd1eb11f7596af28a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Juli=C3=A1n?= Date: Thu, 6 Oct 2016 16:45:53 +0200 Subject: [PATCH] Velocity forecasting --- CHANGELOG.md | 2 + app/coffee/modules/backlog/lightboxes.coffee | 9 ++- app/coffee/modules/backlog/main.coffee | 74 +++++++++++++++++-- app/coffee/modules/common/filters.coffee | 8 ++ app/coffee/modules/resources.coffee | 1 + .../modules/resources/userstories.coffee | 11 +++ app/locales/taiga/locale-en.json | 6 ++ app/partials/backlog/backlog.jade | 42 +++++++++-- .../includes/components/backlog-row.jade | 3 +- .../includes/modules/backlog-table.jade | 2 +- .../modules/lightbox-sprint-add-edit.jade | 2 +- app/styles/components/buttons.scss | 17 +++++ app/styles/layout/backlog.scss | 40 ++++++---- app/styles/modules/backlog/backlog-table.scss | 4 + e2e/helpers/backlog-helper.js | 16 ++++ e2e/suites/backlog.e2e.js | 37 ++++++++++ 16 files changed, 240 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4d17be6..c3b5456c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog # +## 3.1.0 No name yet (no date yet) +- Velocity forecasting. Create sprints according to team velocity. ## 3.0.0 Stellaria Borealis (2016-10-02) diff --git a/app/coffee/modules/backlog/lightboxes.coffee b/app/coffee/modules/backlog/lightboxes.coffee index 324ce8b1..0af40673 100644 --- a/app/coffee/modules/backlog/lightboxes.coffee +++ b/app/coffee/modules/backlog/lightboxes.coffee @@ -38,6 +38,7 @@ CreateEditSprint = ($repo, $confirm, $rs, $rootscope, lightboxService, $loading, createSprint = true form = null $scope.newSprint = {} + ussToAdd = null resetSprint = () -> form.reset() if form @@ -97,7 +98,10 @@ CreateEditSprint = ($repo, $confirm, $rs, $rootscope, lightboxService, $loading, else return it - $rootscope.$broadcast(broadcastEvent, data) + if broadcastEvent == "sprintform:create:success" && ussToAdd + $rootscope.$broadcast(broadcastEvent, data, ussToAdd) + else + $rootscope.$broadcast(broadcastEvent, data) lightboxService.close($el) @@ -135,7 +139,8 @@ CreateEditSprint = ($repo, $confirm, $rs, $rootscope, lightboxService, $loading, return sortedSprints[sortedSprints.length - 1] - $scope.$on "sprintform:create", (event, projectId) -> + $scope.$on "sprintform:create", (event, projectId, uss) -> + ussToAdd = uss resetSprint() form = $el.find("form").checksley() diff --git a/app/coffee/modules/backlog/main.coffee b/app/coffee/modules/backlog/main.coffee index b1527031..6099a528 100644 --- a/app/coffee/modules/backlog/main.coffee +++ b/app/coffee/modules/backlog/main.coffee @@ -86,6 +86,7 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F @showTags = false @activeFilters = false @scope.showGraphPlaceholder = null + @displayVelocity = false @.initializeEventHandlers() @@ -120,8 +121,10 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F @confirm.notify("success") @analytics.trackEvent("userstory", "create", "bulk create userstory on backlog", 1) - @scope.$on "sprintform:create:success", => - @.loadSprints() + @scope.$on "sprintform:create:success", (e, data, ussToMove) => + @.loadSprints().then () => + @scope.$broadcast("sprintform:create:success:callback", ussToMove) + @.loadProjectStats() @confirm.notify("success") @analytics.trackEvent("sprint", "create", "create sprint on backlog", 1) @@ -181,6 +184,17 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F toggleActiveFilters: -> @activeFilters = !@activeFilters + toggleVelocityForecasting: -> + @displayVelocity = !@displayVelocity + if !@displayVelocity + @scope.visibleUserStories = _.map @scope.userstories, (it) -> + return it.ref + else + @scope.visibleUserStories = _.map @.forecastedStories, (it) -> + return it.ref + scopeDefer @scope, => + @scope.$broadcast("userstories:loaded") + loadProjectStats: -> return @rs.projects.stats(@scope.projectId).then (stats) => @scope.stats = stats @@ -192,6 +206,7 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F @scope.stats.completedPercentage = 0 @scope.showGraphPlaceholder = !(stats.total_points? && stats.total_milestones?) + @.calculateForecasting() return stats setMilestonesOrder: (sprints) -> @@ -275,6 +290,7 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F promise = @rs.userstories.listUnassigned(@scope.projectId, params, pageSize) return promise.then (result) => + userstories = result[0] header = result[1] @@ -283,6 +299,8 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F # NOTE: Fix order of USs because the filter orderBy does not work propertly in the partials files @scope.userstories = @scope.userstories.concat(_.sortBy(userstories, "backlog_order")) + @scope.visibleUserStories = _.map @scope.userstories, (it) -> + return it.ref for it in @scope.userstories @.backlogOrder[it.id] = it.backlog_order @@ -305,7 +323,22 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F @.loadProjectStats(), @.loadSprints(), @.loadUserstories() - ]) + ]).then(@.calculateForecasting) + + calculateForecasting: -> + stats = @scope.stats + total_points = stats.total_points + current_sum = stats.assigned_points + backlog_points_sum = 0 + @forecastedStories = [] + + for us in @scope.userstories + current_sum += us.total_points + backlog_points_sum += us.total_points + @forecastedStories.push(us) + + if stats.speed > 0 && backlog_points_sum > stats.speed + break loadProject: -> return @rs.projects.getBySlug(@params.pslug).then (project) => @@ -545,7 +578,7 @@ module.controller("BacklogController", BacklogController) ## Backlog Directive ############################################################################# -BacklogDirective = ($repo, $rootscope, $translate) -> +BacklogDirective = ($repo, $rootscope, $translate, $rs) -> ## Doom line Link doomLineTemplate = _.template("""
<%- text %>
@@ -553,11 +586,13 @@ BacklogDirective = ($repo, $rootscope, $translate) -> linkDoomLine = ($scope, $el, $attrs, $ctrl) -> reloadDoomLine = -> - if $scope.stats? and $scope.stats.total_points? and $scope.stats.total_points != 0 + if $scope.displayVelocity + removeDoomlineDom() + + if $scope.stats? and $scope.stats.total_points? and $scope.stats.total_points != 0 and !$scope.displayVelocity? removeDoomlineDom() stats = $scope.stats - total_points = stats.total_points current_sum = stats.assigned_points @@ -584,6 +619,7 @@ BacklogDirective = ($repo, $rootscope, $translate) -> return _.map(rowElements, (x) -> angular.element(x)) $scope.$on("userstories:loaded", reloadDoomLine) + $scope.$on("userstories:forecast", removeDoomlineDom) $scope.$watch("stats", reloadDoomLine) ## Move to current sprint link @@ -614,9 +650,11 @@ BacklogDirective = ($repo, $rootscope, $translate) -> # Update the total of points sprint.total_points += totalExtraPoints - $repo.saveAll(selectedUss).then -> + $rs.userstories.bulkUpdateMilestone($scope.project.id, $scope.sprints[0].id, selectedUss).then => $ctrl.loadSprints() $ctrl.loadProjectStats() + $ctrl.toggleVelocityForecasting() + $ctrl.calculateForecasting() $el.find(".move-to-sprint").hide() @@ -626,6 +664,9 @@ BacklogDirective = ($repo, $rootscope, $translate) -> moveToLatestSprint = (selectedUss) -> moveUssToSprint(selectedUss, $scope.sprints[0]) + $scope.$on "sprintform:create:success:callback", (e, ussToMove) -> + _.partial(moveToCurrentSprint, ussToMove)() + shiftPressed = false lastChecked = null @@ -640,6 +681,7 @@ BacklogDirective = ($repo, $rootscope, $translate) -> else moveToSprintDom.hide() + $(window).on "keydown.shift-pressed keyup.shift-pressed", (event) -> shiftPressed = !!event.shiftKey @@ -685,6 +727,22 @@ BacklogDirective = ($repo, $rootscope, $translate) -> showHideTags($ctrl) + $el.on "click", ".forecasting-add-sprint", (event) -> + ussToMoveList = $ctrl.forecastedStories + if $scope.currentSprint + ussToMove = _.map ussToMoveList, (us, index) -> + us.milestone = $scope.currentSprint.id + us.order = index + return us + + $scope.$apply(_.partial(moveToCurrentSprint, ussToMove)) + else + ussToMove = _.map ussToMoveList, (us, index) -> + us.order = index + return us + + $rootscope.$broadcast("sprintform:create", $scope.projectId, ussToMove) + showHideTags = ($ctrl) -> elm = angular.element("#show-tags") @@ -759,7 +817,7 @@ BacklogDirective = ($repo, $rootscope, $translate) -> return {link: link} -module.directive("tgBacklog", ["$tgRepo", "$rootScope", "$translate", BacklogDirective]) +module.directive("tgBacklog", ["$tgRepo", "$rootScope", "$translate", "$tgResources", BacklogDirective]) ############################################################################# ## User story points directive diff --git a/app/coffee/modules/common/filters.coffee b/app/coffee/modules/common/filters.coffee index c232d85d..339e2fa6 100644 --- a/app/coffee/modules/common/filters.coffee +++ b/app/coffee/modules/common/filters.coffee @@ -127,3 +127,11 @@ darkerFilter = -> module.filter("darker", darkerFilter) + +inArray = ($filter) -> + return (list, arrayFilter, element) -> + if arrayFilter + filter = $filter("filter") + return filter list, (listItem) -> + return arrayFilter.indexOf(listItem[element]) != -1 +module.filter("inArray", ["$filter", inArray]) diff --git a/app/coffee/modules/resources.coffee b/app/coffee/modules/resources.coffee index 20b9fa9a..f8b0a7be 100644 --- a/app/coffee/modules/resources.coffee +++ b/app/coffee/modules/resources.coffee @@ -109,6 +109,7 @@ urls = { "bulk-update-us-milestone": "/userstories/bulk_update_milestone" "bulk-update-us-miles-order": "/userstories/bulk_update_sprint_order" "bulk-update-us-kanban-order": "/userstories/bulk_update_kanban_order" + "bulk-update-us-milestone": "/userstories/bulk_update_milestone" "userstories-filters": "/userstories/filters_data" "userstory-upvote": "/userstories/%s/upvote" "userstory-downvote": "/userstories/%s/downvote" diff --git a/app/coffee/modules/resources/userstories.coffee b/app/coffee/modules/resources/userstories.coffee index f4c5cca4..e95693d2 100644 --- a/app/coffee/modules/resources/userstories.coffee +++ b/app/coffee/modules/resources/userstories.coffee @@ -108,6 +108,17 @@ resourceProvider = ($repo, $http, $urls, $storage, $q) -> params = {project_id: projectId, bulk_stories: data} return $http.post(url, params) + service.bulkUpdateMilestone = (projectId, milestoneId, data) -> + url = $urls.resolve("bulk-update-us-milestone") + data = _.map data, (us) -> + return { + us_id: us.id + order: us.order + } + + params = {project_id: projectId, milestone_id: milestoneId, bulk_stories: data} + return $http.post(url, params) + service.listValues = (projectId, type) -> params = {"project": projectId} service.storeQueryParams(projectId, params) diff --git a/app/locales/taiga/locale-en.json b/app/locales/taiga/locale-en.json index 15ae363e..7127c031 100644 --- a/app/locales/taiga/locale-en.json +++ b/app/locales/taiga/locale-en.json @@ -1249,6 +1249,12 @@ "SHOW": "Show tags", "HIDE": "Hide tags" }, + "FORECASTING": { + "TITLE": "Velocity forecasting", + "BACKLOG": "Display backlog", + "NEW_SPRINT": "Candidate User Stories for your next sprint based on your velocity. Click to create a new sprint.", + "CURRENT_SPRINT": "Candidate User Stories for your sprint based on your velocity. Click to add to current sprint." + }, "TABLE": { "COLUMN_US": "User Stories", "TITLE_COLUMN_POINTS": "Select view per Role" diff --git a/app/partials/backlog/backlog.jade b/app/partials/backlog/backlog.jade index be444cc8..941f3ce4 100644 --- a/app/partials/backlog/backlog.jade +++ b/app/partials/backlog/backlog.jade @@ -36,7 +36,7 @@ div.wrapper(tg-backlog, ng-controller="BacklogController as ctrl", div.backlog-menu div.backlog-table-options - a.trans-button.menu-button.move-to-current-sprint.move-to-sprint.e2e-move-to-sprint( + a.menu-button.move-to-current-sprint.move-to-sprint.e2e-move-to-sprint( ng-if="currentSprint" href="" title="{{'BACKLOG.MOVE_US_TO_CURRENT_SPRINT' | translate}}" @@ -44,7 +44,7 @@ div.wrapper(tg-backlog, ng-controller="BacklogController as ctrl", ) tg-svg(svg-icon="icon-move") span.text(translate="BACKLOG.MOVE_US_TO_CURRENT_SPRINT") - a.trans-button.menu-button.move-to-latest-sprint.move-to-sprint.e2e-move-to-sprint( + a.menu-button.move-to-latest-sprint.move-to-sprint.e2e-move-to-sprint( ng-if="!currentSprint" href="" title="{{'BACKLOG.MOVE_US_TO_LATEST_SPRINT' | translate}}" @@ -52,33 +52,61 @@ div.wrapper(tg-backlog, ng-controller="BacklogController as ctrl", ) tg-svg(svg-icon="icon-move") span.text(translate="BACKLOG.MOVE_US_TO_LATEST_SPRINT") - a.trans-button.menu-button.e2e-open-filter.ng-animate-disabled( + a.menu-button.e2e-open-filter.ng-animate-disabled( ng-if="!ctrl.activeFilters" href="" title="{{'BACKLOG.FILTERS.TOGGLE' | translate}}" id="show-filters-button" translate="BACKLOG.FILTERS.SHOW" ) - a.trans-button.menu-button.active.e2e-open-filter.ng-animate-disabled( + a.menu-button.active.e2e-open-filter.ng-animate-disabled( ng-if="ctrl.activeFilters" href="" title="{{'BACKLOG.FILTERS.HIDE' | translate}}" id="show-filters-button" translate="BACKLOG.FILTERS.HIDE" ) - a.trans-button.menu-button( + a.menu-button( ng-if="userstories.length" href="" title="{{'BACKLOG.TAGS.TOGGLE' | translate}}" id="show-tags" translate="BACKLOG.TAGS.SHOW" ) + a.menu-button.velocity-forecasting-btn.ng-animate-disabled.e2e-velocity-forecasting( + ng-if="userstories.length && ctrl.displayVelocity " + href="" + title="{{'BACKLOG.FORECASTING.TITLE' | translate}}" + translate="BACKLOG.FORECASTING.BACKLOG" + ng-click="ctrl.toggleVelocityForecasting()" + tg-check-permission="add_milestone" + ) + a.menu-button.velocity-forecasting-btn.ng-animate-disabled.e2e-velocity-forecasting( + ng-if="userstories.length && !ctrl.displayVelocity && stats.speed > 0" + href="" + title="{{'BACKLOG.FORECASTING.BACKLOG' | translate}}" + translate="BACKLOG.FORECASTING.TITLE" + ng-click="ctrl.toggleVelocityForecasting()" + tg-check-permission="add_milestone" + ) include ../includes/components/addnewus + section.backlog-table(ng-class="{'hidden': !userstories.length}") include ../includes/modules/backlog-table - - div.empty-large.js-empty-backlog(ng-class="{'hidden': userstories === undefined || userstories.length}") + + .forecasting-add-sprint.e2e-velocity-forecasting-add(ng-if="ctrl.displayVelocity") + tg-svg(svg-icon="icon-add") + span( + ng-if="!currentSprint" + translate="BACKLOG.FORECASTING.NEW_SPRINT" + ) + span( + ng-if="currentSprint" + translate="BACKLOG.FORECASTING.CURRENT_SPRINT" + ) + + .empty-large.js-empty-backlog(ng-class="{'hidden': userstories === undefined || userstories.length}") img( src="/#{v}/images/empty/empty_mex.png" alt="{{'BACKLOG.EMPTY' | translate}}" diff --git a/app/partials/includes/components/backlog-row.jade b/app/partials/includes/components/backlog-row.jade index 4a3a6143..08524602 100644 --- a/app/partials/includes/components/backlog-row.jade +++ b/app/partials/includes/components/backlog-row.jade @@ -1,9 +1,10 @@ .row.us-item-row( - ng-repeat="us in userstories track by us.id" + ng-repeat="us in userstories | inArray:visibleUserStories:'ref'" tg-bind-scope ng-class="{blocked: us.is_blocked}" tg-class-permission="{'readonly': '!modify_us'}" ) + .input(tg-check-permission="modify_us") input( type="checkbox" diff --git a/app/partials/includes/modules/backlog-table.jade b/app/partials/includes/modules/backlog-table.jade index 8ee5707b..c37e329f 100644 --- a/app/partials/includes/modules/backlog-table.jade +++ b/app/partials/includes/modules/backlog-table.jade @@ -10,7 +10,7 @@ div.backlog-table-header div.backlog-table-body( tg-backlog-sortable, - ng-class="{'show-tags': ctrl.showTags, 'active-filters': ctrl.activeFilters}" + ng-class="{'show-tags': ctrl.showTags, 'active-filters': ctrl.activeFilters, 'forecasted-stories': ctrl.displayVelocity}" infinite-scroll="ctrl.loadUserstories()" infinite-scroll-disabled="ctrl.disablePagination || !ctrl.firstLoadComplete" infinite-scroll-immediate-check='false' diff --git a/app/partials/includes/modules/lightbox-sprint-add-edit.jade b/app/partials/includes/modules/lightbox-sprint-add-edit.jade index 254e3276..d2a2400a 100644 --- a/app/partials/includes/modules/lightbox-sprint-add-edit.jade +++ b/app/partials/includes/modules/lightbox-sprint-add-edit.jade @@ -3,7 +3,7 @@ tg-lightbox-close form h2.title(translate="LIGHTBOX.ADD_EDIT_SPRINT.TITLE") fieldset - input.sprint-name( + input.sprint-name.e2e-sprint-name( type="text" name="name" ng-model="newSprint.name" diff --git a/app/styles/components/buttons.scss b/app/styles/components/buttons.scss index 96dbb3b5..071feaf8 100755 --- a/app/styles/components/buttons.scss +++ b/app/styles/components/buttons.scss @@ -59,6 +59,23 @@ color: $blackish; } } + +.menu-button { + @extend %button; + border-radius: 0; + color: $blackish; + &:hover { + background: $whitish; + color: $gray; + } + &:visited { + color: $blackish; + } + span { + color: $blackish; + } +} + .submit-button { width: 100%; } diff --git a/app/styles/layout/backlog.scss b/app/styles/layout/backlog.scss index f59fda27..e6feca25 100644 --- a/app/styles/layout/backlog.scss +++ b/app/styles/layout/backlog.scss @@ -25,24 +25,16 @@ display: flex; justify-content: space-between; margin-bottom: 1rem; + @include breakpoint(laptop) { + flex-direction: column; + } .menu-button { - border-radius: 0; - color: $blackish; - display: inline-block; - padding: .4rem 1.5rem; - &.active, - &:hover { - background: $whitish; - color: $gray; - } - &.active { - &:hover { - background: darken($whitish, 10%); - } - } &.move-to-sprint { display: none; } + .icon-move { + margin-right: .25rem; + } } .button-bulk { margin-left: .2rem; @@ -70,3 +62,23 @@ background: $white; } } + +.forecasting-add-sprint { + @include font-size(small); + background: $mass-white; + cursor: pointer; + padding: .5rem 0; + text-align: center; + &:hover { + background: darken($mass-white, 3%); + transition: background .2s; + } + .icon-add { + @include svg-size(1.75rem); + background: $primary-light; + fill: $white; + margin-right: 1rem; + padding: .25rem; + vertical-align: middle; + } +} diff --git a/app/styles/modules/backlog/backlog-table.scss b/app/styles/modules/backlog/backlog-table.scss index be8607f0..61a4d5c7 100644 --- a/app/styles/modules/backlog/backlog-table.scss +++ b/app/styles/modules/backlog/backlog-table.scss @@ -145,6 +145,10 @@ } .backlog-table-body { + &.forecasted-stories { + border: .5rem solid $mass-white; + border-bottom: 0; + } .row { border-bottom: 1px solid darken($whitish, 4%); cursor: move; diff --git a/e2e/helpers/backlog-helper.js b/e2e/helpers/backlog-helper.js index 932e1c34..4c1f1f19 100644 --- a/e2e/helpers/backlog-helper.js +++ b/e2e/helpers/backlog-helper.js @@ -131,6 +131,22 @@ helper.openNewUs = function() { $$('.new-us a').get(0).click(); }; +helper.velocityForecasting = function() { + return $$('.e2e-velocity-forecasting'); +}; + +helper.openVelocityForecasting = function() { + $$('.e2e-velocity-forecasting').click(); +}; + +helper.createSprintFromForecasting = function() { + $$('.e2e-velocity-forecasting-add').click(); + let sprintName = 'sprintName' + new Date().getTime(); + $('.e2e-sprint-name') + .sendKeys(sprintName) + .sendKeys(protractor.Key.ENTER); +}; + helper.openUsBacklogEdit = function(item) { $$('.backlog-table-body .e2e-edit').get(item).click(); }; diff --git a/e2e/suites/backlog.e2e.js b/e2e/suites/backlog.e2e.js index f847a4f2..958ea18c 100644 --- a/e2e/suites/backlog.e2e.js +++ b/e2e/suites/backlog.e2e.js @@ -449,6 +449,43 @@ describe('backlog', function() { }); }); + describe('velocity forecasting', function() { + it('show', async function() { + browser.get(browser.params.glob.host + 'project/project-1/backlog'); + await utils.common.waitLoader(); + + let usCount = await backlogHelper.userStories().count(); + + await backlogHelper.openVelocityForecasting(); + utils.common.takeScreenshot('backlog', 'velocity-forecasting'); + + let newUsCount = await backlogHelper.userStories().count(); + + expect(newUsCount).is.below(usCount); + }); + it('create sprint from forecasting', async function() { + browser.get(browser.params.glob.host + 'project/project-1/backlog'); + await utils.common.waitLoader(); + + let sprintCount = await backlogHelper.sprintsOpen().count(); + + backlogHelper.openVelocityForecasting(); + backlogHelper.createSprintFromForecasting(); + + let newSprintCount = await backlogHelper.sprintsOpen().count(); + + expect(sprintCount).is.below(newSprintCount); + }); + it.only('hide forecasting if no velocity', async function() { + browser.get(browser.params.glob.host + 'project/project-5/backlog'); + await utils.common.waitLoader(); + + let forecasting = await backlogHelper.velocityForecasting(); + + expect(forecasting).to.be.empty; + }); + }); + describe('backlog filters', sharedFilters.bind(this, 'backlog', () => { return backlogHelper.userStories().count(); }));