Merge pull request #1127 from taigaio/us/1563/velocity-forecasting

Velocity forecasting
stable
Juanfran 2016-10-31 08:34:23 +01:00 committed by GitHub
commit d19c431f16
16 changed files with 240 additions and 34 deletions

View File

@ -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)

View File

@ -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()

View File

@ -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("""
<div class="doom-line"><span><%- text %></span></div>
@ -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

View File

@ -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])

View File

@ -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"

View File

@ -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)

View File

@ -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"

View File

@ -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}}"

View File

@ -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"

View File

@ -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'

View File

@ -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"

View File

@ -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%;
}

View File

@ -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;
}
}

View File

@ -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;

View File

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

View File

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