###
# Copyright (C) 2014-2018 Taiga Agile LLC
#
# 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
timeout = @.taiga.timeout
bindMethods = @.taiga.bindMethods
debounceLeading = @.taiga.debounceLeading
module = angular.module("taigaKanban")
#############################################################################
## Kanban Controller
#############################################################################
class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin, taiga.UsFiltersMixin)
@.$inject = [
"$scope",
"$rootScope",
"$tgRepo",
"$tgConfirm",
"$tgResources",
"tgResources",
"$routeParams",
"$q",
"$tgLocation",
"tgAppMetaService",
"$tgNavUrls",
"$tgEvents",
"$tgAnalytics",
"$translate",
"tgErrorHandlingService",
"$tgModel",
"tgKanbanUserstories",
"$tgStorage",
"tgFilterRemoteStorageService",
"tgProjectService"
]
storeCustomFiltersName: 'kanban-custom-filters'
storeFiltersName: 'kanban-filters'
constructor: (@scope, @rootscope, @repo, @confirm, @rs, @rs2, @params, @q, @location,
@appMetaService, @navUrls, @events, @analytics, @translate, @errorHandlingService,
@model, @kanbanUserstoriesService, @storage, @filterRemoteStorageService, @projectService) ->
bindMethods(@)
@kanbanUserstoriesService.reset()
@.openFilter = false
@.selectedUss = {}
return if @.applyStoredFilters(@params.pslug, "kanban-filters")
@scope.sectionName = @translate.instant("KANBAN.SECTION_NAME")
@.initializeEventHandlers()
taiga.defineImmutableProperty @.scope, "usByStatus", () =>
return @kanbanUserstoriesService.usByStatus
cleanSelectedUss: () ->
for key of @.selectedUss
@.selectedUss[key] = false
toggleSelectedUs: (usId) ->
@.selectedUss[usId] = !@.selectedUss[usId]
firstLoad: () ->
promise = @.loadInitialData()
# On Success
promise.then =>
title = @translate.instant("KANBAN.PAGE_TITLE", {projectName: @scope.project.name})
description = @translate.instant("KANBAN.PAGE_DESCRIPTION", {
projectName: @scope.project.name,
projectDescription: @scope.project.description
})
@appMetaService.setAll(title, description)
# On Error
promise.then null, @.onInitialDataError.bind(@)
setZoom: (zoomLevel, zoom) ->
if @.zoomLevel == zoomLevel
return null
@.isFirstLoad = !@.zoomLevel
previousZoomLevel = @.zoomLevel
@.zoomLevel = zoomLevel
@.zoom = zoom
if @.isFirstLoad
@.firstLoad().then () =>
@.isFirstLoad = false
@kanbanUserstoriesService.resetFolds()
else if @.zoomLevel > 1 && previousZoomLevel <= 1
@.zoomLoading = true
@.loadUserstories().then () =>
@.zoomLoading = false
@kanbanUserstoriesService.resetFolds()
filtersReloadContent: () ->
@.loadUserstories().then () =>
openArchived = _.difference(@kanbanUserstoriesService.archivedStatus,
@kanbanUserstoriesService.statusHide)
if openArchived.length
for statusId in openArchived
@.loadUserStoriesForStatus({}, statusId)
initializeEventHandlers: ->
@scope.$on "usform:new:success", (event, us) =>
@.refreshTagsColors().then () =>
@kanbanUserstoriesService.add(us)
@analytics.trackEvent("userstory", "create", "create userstory on kanban", 1)
@scope.$on "usform:bulk:success", (event, uss) =>
@.refreshTagsColors().then () =>
@kanbanUserstoriesService.add(uss)
@analytics.trackEvent("userstory", "create", "bulk create userstory on kanban", 1)
@scope.$on "usform:edit:success", (event, us) =>
@.refreshTagsColors().then () =>
@kanbanUserstoriesService.replaceModel(us)
@scope.$on "kanban:us:deleted", (event, us) =>
@.filtersReloadContent()
@scope.$on("assigned-to:added", @.onAssignedToChanged)
@scope.$on("assigned-user:added", @.onAssignedUsersChanged)
@scope.$on("assigned-user:deleted", @.onAssignedUsersDeleted)
@scope.$on("kanban:us:move", @.moveUs)
@scope.$on("kanban:show-userstories-for-status", @.loadUserStoriesForStatus)
@scope.$on("kanban:hide-userstories-for-status", @.hideUserStoriesForStatus)
addNewUs: (type, statusId) ->
switch type
when "standard" then @rootscope.$broadcast("genericform:new",
{
'objType': 'us',
'project': @scope.project,
'statusId': statusId
})
when "bulk" then @rootscope.$broadcast("usform:bulk",
@scope.projectId, statusId)
editUs: (id) ->
us = @kanbanUserstoriesService.getUs(id)
us = us.set('loading-edit', true)
@kanbanUserstoriesService.replace(us)
@rs.userstories.getByRef(us.getIn(['model', 'project']), us.getIn(['model', 'ref']))
.then (editingUserStory) =>
@rs2.attachments.list(
"us", us.get('id'), us.getIn(['model', 'project'])).then (attachments) =>
@rootscope.$broadcast("genericform:edit", {
'objType': 'us',
'obj': editingUserStory,
'statusList': @scope.usStatusList,
'attachments': attachments.toJS()
})
us = us.set('loading-edit', false)
@kanbanUserstoriesService.replace(us)
deleteUs: (id) ->
us = @kanbanUserstoriesService.getUs(id)
us = us.set('loading-delete', true)
@rs.userstories.getByRef(us.getIn(['model', 'project']), us.getIn(['model', 'ref']))
.then (deletingUserStory) =>
us = us.set('loading-delete', false)
title = @translate.instant("US.TITLE_DELETE_ACTION")
message = deletingUserStory.subject
@confirm.askOnDelete(title, message).then (askResponse) =>
promise = @repo.remove(deletingUserStory)
promise.then =>
@scope.$broadcast("kanban:us:deleted")
askResponse.finish()
promise.then null, ->
askResponse.finish(false)
@confirm.notify("error")
showPlaceHolder: (statusId) ->
if @scope.usStatusList[0].id == statusId &&
!@kanbanUserstoriesService.userstoriesRaw.length
return true
return false
toggleFold: (id) ->
@kanbanUserstoriesService.toggleFold(id)
isUsInArchivedHiddenStatus: (usId) ->
return @kanbanUserstoriesService.isUsInArchivedHiddenStatus(usId)
changeUsAssignedTo: (id) ->
us = @kanbanUserstoriesService.getUsModel(id)
@rootscope.$broadcast("assigned-to:add", us)
changeUsAssignedUsers: (id) ->
us = @kanbanUserstoriesService.getUsModel(id)
@rootscope.$broadcast("assigned-user:add", us)
onAssignedToChanged: (ctx, userid, usModel) ->
usModel.assigned_to = userid
@kanbanUserstoriesService.replaceModel(usModel)
@repo.save(usModel).then =>
@.generateFilters()
if @.isFilterDataTypeSelected('assigned_to') || @.isFilterDataTypeSelected('role')
@.filtersReloadContent()
onAssignedUsersChanged: (ctx, userid, usModel) ->
assignedUsers = _.clone(usModel.assigned_users, false)
assignedUsers.push(userid)
assignedUsers = _.uniq(assignedUsers)
usModel.assigned_users = assignedUsers
if not usModel.assigned_to
usModel.assigned_to = userid
@kanbanUserstoriesService.replaceModel(usModel)
@repo.save(usModel).then =>
@.generateFilters()
if @.isFilterDataTypeSelected('assigned_users') || @.isFilterDataTypeSelected('role')
@.filtersReloadContent()
onAssignedUsersDeleted: (ctx, userid, usModel) ->
assignedUsersIds = _.clone(usModel.assigned_users, false)
assignedUsersIds = _.pull(assignedUsersIds, userid)
assignedUsersIds = _.uniq(assignedUsersIds)
usModel.assigned_users = assignedUsersIds
# Update as
if usModel.assigned_to not in assignedUsersIds and assignedUsersIds.length > 0
usModel.assigned_to = assignedUsersIds[0]
if assignedUsersIds.length == 0
usModel.assigned_to = null
@kanbanUserstoriesService.replaceModel(usModel)
@repo.save(usModel).then =>
@.generateFilters()
if @.isFilterDataTypeSelected('assigned_users') || @.isFilterDataTypeSelected('role')
@.filtersReloadContent()
refreshTagsColors: ->
return @rs.projects.tagsColors(@scope.projectId).then (tags_colors) =>
@scope.project.tags_colors = tags_colors._attrs
loadUserstories: () ->
params = {
status__is_archived: false
}
if @.zoomLevel > 1
params.include_attachments = 1
params.include_tasks = 1
params = _.merge params, @location.search()
promise = @rs.userstories.listAll(@scope.projectId, params).then (userstories) =>
@kanbanUserstoriesService.init(@scope.project, @scope.usersById)
@kanbanUserstoriesService.set(userstories)
# 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", userstories)
return userstories
promise.then( => @scope.$broadcast("redraw:wip"))
return promise
loadUserStoriesForStatus: (ctx, statusId) ->
filteredStatus = @location.search().status
# if there are filters applied the action doesn't end if the statusId is not in the url
if filteredStatus
filteredStatus = filteredStatus.split(",").map (it) -> parseInt(it, 10)
return if filteredStatus.indexOf(statusId) == -1
params = {
status: statusId
include_attachments: true,
include_tasks: true
}
params = _.merge params, @location.search()
return @rs.userstories.listAll(@scope.projectId, params).then (userstories) =>
@scope.$broadcast("kanban:shown-userstories-for-status", statusId, userstories)
return userstories
hideUserStoriesForStatus: (ctx, statusId) ->
@scope.$broadcast("kanban:hidden-userstories-for-status", statusId)
loadKanban: ->
return @q.all([
@.refreshTagsColors(),
@.loadUserstories()
])
loadProject: ->
project = @projectService.project.toJS()
if not project.is_kanban_activated
@errorHandlingService.permissionDenied()
@scope.projectId = project.id
@scope.project = project
@scope.projectId = project.id
@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, "order")
@scope.$emit("project:loaded", project)
return project
initializeSubscription: ->
routingKey1 = "changes.project.#{@scope.projectId}.userstories"
randomTimeout = taiga.randomInt(700, 1000)
@events.subscribe @scope, routingKey1, debounceLeading(randomTimeout, (message) =>
@.loadUserstories())
loadInitialData: ->
project = @.loadProject()
@.fillUsersAndRoles(project.members, project.roles)
@.initializeSubscription()
@.loadKanban()
@.generateFilters()
# Utils methods
prepareBulkUpdateData: (uses, field="kanban_order") ->
return _.map(uses, (x) -> {"us_id": x.id, "order": x[field]})
moveUs: (ctx, usList, newStatusId, index) ->
@.cleanSelectedUss()
usList = _.map usList, (us) =>
return @kanbanUserstoriesService.getUsModel(us.id)
data = @kanbanUserstoriesService.move(usList, newStatusId, index)
promise = @rs.userstories.bulkUpdateKanbanOrder(@scope.projectId, newStatusId, data.bulkOrders)
promise.then () =>
# saving
# drag single or different status
options = {
headers: {
"set-orders": JSON.stringify(data.setOrders)
}
}
params = {
include_attachments: true,
include_tasks: true
}
promises = _.map usList, (us) =>
@repo.save(us, true, params, options, true)
promise = @q.all(promises)
promise.then (result) =>
headers = result[1]
if headers && headers['taiga-info-order-updated']
order = JSON.parse(headers['taiga-info-order-updated'])
@kanbanUserstoriesService.assignOrders(order)
@scope.$broadcast("redraw:wip")
@.generateFilters()
if @.isFilterDataTypeSelected('status')
@.filtersReloadContent()
return promise
module.controller("KanbanController", KanbanController)
#############################################################################
## Kanban Directive
#############################################################################
KanbanDirective = ($repo, $rootscope) ->
link = ($scope, $el, $attrs) ->
tableBodyDom = $el.find(".kanban-table-body")
tableBodyDom.on "scroll", (event) ->
target = angular.element(event.currentTarget)
tableHeaderDom = $el.find(".kanban-table-header .kanban-table-inner")
tableHeaderDom.css("left", -1 * target.scrollLeft())
$scope.$on "$destroy", ->
$el.off()
return {link: link}
module.directive("tgKanban", ["$tgRepo", "$rootScope", KanbanDirective])
#############################################################################
## Kanban Archived Status Column Header Control
#############################################################################
KanbanArchivedStatusHeaderDirective = ($rootscope, $translate, kanbanUserstoriesService) ->
showArchivedText = $translate.instant("KANBAN.ACTION_SHOW_ARCHIVED")
hideArchivedText = $translate.instant("KANBAN.ACTION_HIDE_ARCHIVED")
link = ($scope, $el, $attrs) ->
status = $scope.$eval($attrs.tgKanbanArchivedStatusHeader)
hidden = true
kanbanUserstoriesService.addArchivedStatus(status.id)
kanbanUserstoriesService.hideStatus(status.id)
$scope.class = "icon-watch"
$scope.title = showArchivedText
$el.on "click", (event) ->
hidden = not hidden
$scope.$apply ->
if hidden
$scope.class = "icon-watch"
$scope.title = showArchivedText
$rootscope.$broadcast("kanban:hide-userstories-for-status", status.id)
kanbanUserstoriesService.hideStatus(status.id)
else
$scope.class = "icon-unwatch"
$scope.title = hideArchivedText
$rootscope.$broadcast("kanban:show-userstories-for-status", status.id)
kanbanUserstoriesService.showStatus(status.id)
$scope.$on "$destroy", ->
$el.off()
return {link:link}
module.directive("tgKanbanArchivedStatusHeader", [ "$rootScope", "$translate", "tgKanbanUserstories", KanbanArchivedStatusHeaderDirective])
#############################################################################
## Kanban Archived Status Column Intro Directive
#############################################################################
KanbanArchivedStatusIntroDirective = ($translate, kanbanUserstoriesService) ->
userStories = []
link = ($scope, $el, $attrs) ->
hiddenUserStoriexText = $translate.instant("KANBAN.HIDDEN_USER_STORIES")
status = $scope.$eval($attrs.tgKanbanArchivedStatusIntro)
$el.text(hiddenUserStoriexText)
updateIntroText = (hasArchived) ->
if hasArchived
$el.text("")
else
$el.text(hiddenUserStoriexText)
$scope.$on "kanban:us:move", (ctx, itemUs, oldStatusId, newStatusId, itemIndex) ->
hasArchived = !!kanbanUserstoriesService.getStatus(newStatusId).length
updateIntroText(hasArchived)
$scope.$on "kanban:shown-userstories-for-status", (ctx, statusId, userStoriesLoaded) ->
if statusId == status.id
kanbanUserstoriesService.deleteStatus(statusId)
kanbanUserstoriesService.add(userStoriesLoaded)
hasArchived = !!kanbanUserstoriesService.getStatus(statusId).length
updateIntroText(hasArchived)
$scope.$on "kanban:hidden-userstories-for-status", (ctx, statusId) ->
if statusId == status.id
updateIntroText(false)
$scope.$on "$destroy", ->
$el.off()
return {link:link}
module.directive("tgKanbanArchivedStatusIntro", ["$translate", "tgKanbanUserstories", KanbanArchivedStatusIntroDirective])
#############################################################################
## Kanban Squish Column Directive
#############################################################################
KanbanSquishColumnDirective = (rs, projectService) ->
link = ($scope, $el, $attrs) ->
$scope.foldStatus = (status) ->
$scope.folds[status.id] = !!!$scope.folds[status.id]
rs.kanban.storeStatusColumnModes($scope.projectId, $scope.folds)
updateTableWidth()
return
updateTableWidth = ->
columnWidths = _.map $scope.usStatusList, (status) ->
if $scope.folds[status.id]
return 40
else
return 310
totalWidth = _.reduce columnWidths, (total, width) ->
return total + width
$el.find('.kanban-table-inner').css("width", totalWidth)
unwatch = $scope.$watch 'usByStatus', (usByStatus) ->
if usByStatus.size
$scope.folds = rs.kanban.getStatusColumnModes(projectService.project.get('id'))
updateTableWidth()
unwatch()
return {link: link}
module.directive("tgKanbanSquishColumn", ["$tgResources", "tgProjectService", KanbanSquishColumnDirective])
#############################################################################
## Kanban WIP Limit Directive
#############################################################################
KanbanWipLimitDirective = ($timeout) ->
link = ($scope, $el, $attrs) ->
status = $scope.$eval($attrs.tgKanbanWipLimit)
redrawWipLimit = =>
$el.find(".kanban-wip-limit").remove()
$timeout =>
element = $el.find("tg-card")[status.wip_limit]
if element
angular.element(element).before("
")
if status and not status.is_archived
$scope.$on "redraw:wip", redrawWipLimit
$scope.$on "kanban:us:move", redrawWipLimit
$scope.$on "usform:new:success", redrawWipLimit
$scope.$on "usform:bulk:success", redrawWipLimit
$scope.$on "$destroy", ->
$el.off()
return {link: link}
module.directive("tgKanbanWipLimit", ["$timeout", KanbanWipLimitDirective])