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();
}));