### # 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/taskboard/main.coffee ### taiga = @.taiga toggleText = @.taiga.toggleText mixOf = @.taiga.mixOf groupBy = @.taiga.groupBy bindOnce = @.taiga.bindOnce scopeDefer = @.taiga.scopeDefer timeout = @.taiga.timeout bindMethods = @.taiga.bindMethods debounceLeading = @.taiga.debounceLeading module = angular.module("taigaTaskboard") ############################################################################# ## Taskboard Controller ############################################################################# class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin) @.$inject = [ "$scope", "$rootScope", "$tgRepo", "$tgConfirm", "$tgResources", "tgResources" "$routeParams", "$q", "tgAppMetaService", "$tgLocation", "$tgNavUrls" "$tgEvents" "$tgAnalytics", "$translate", "tgErrorHandlingService", "tgTaskboardTasks", "tgTaskboardIssues", "$tgStorage", "tgFilterRemoteStorageService" ] excludePrefix: "exclude_" filterCategories: [ "tags", "status", "assigned_to", "owner", "role", ] constructor: (@scope, @rootscope, @repo, @confirm, @rs, @rs2, @params, @q, @appMetaService, @location, @navUrls, @events, @analytics, @translate, @errorHandlingService, @taskboardTasksService, @taskboardIssuesService, @storage, @filterRemoteStorageService) -> bindMethods(@) @taskboardTasksService.reset() @scope.userstories = [] @.openFilter = false return if @.applyStoredFilters(@params.pslug, "tasks-filters") @scope.sectionName = @translate.instant("TASKBOARD.SECTION_NAME") @.initializeEventHandlers() taiga.defineImmutableProperty @.scope, "usTasks", () => return @taskboardTasksService.usTasks taiga.defineImmutableProperty @.scope, "milestoneIssues", () => return @taskboardIssuesService.milestoneIssues firstLoad: () -> promise = @.loadInitialData() # On Success promise.then => @._setMeta() # 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 @taskboardTasksService.resetFolds() else if @.zoomLevel > 1 && previousZoomLevel <= 1 @.zoomLoading = true @q.all([@.loadTasks(), @.loadIssues()]).then () => @.zoomLoading = false @taskboardTasksService.resetFolds() if @.zoomLevel == '0' @rootscope.$broadcast("sprint:zoom0") changeQ: (q) -> @.replaceFilter("q", q) @.loadTasks() @.generateFilters() removeFilter: (filter) -> @.unselectFilter(filter.dataType, filter.id, false, filter.mode) @.loadTasks() @.generateFilters() addFilter: (newFilter) -> @.selectFilter(newFilter.category.dataType, newFilter.filter.id, false, newFilter.mode) @.loadTasks() @.generateFilters() selectCustomFilter: (customFilter) -> @.replaceAllFilters(customFilter.filter) @.loadTasks() @.generateFilters() removeCustomFilter: (customFilter) -> @filterRemoteStorageService.getFilters(@scope.projectId, 'tasks-custom-filters').then (userFilters) => delete userFilters[customFilter.id] @filterRemoteStorageService.storeFilters(@scope.projectId, userFilters, 'tasks-custom-filters').then(@.generateFilters) isFilterDataTypeSelected: (filterDataType) -> for filter in @.selectedFilters if (filter['dataType'] == filterDataType) return true return false saveCustomFilter: (name) -> filters = {} urlfilters = @location.search() for key in @.filterCategories excludeKey = @.excludePrefix.concat(key) filters[key] = urlfilters[key] filters[excludeKey] = urlfilters[excludeKey] @filterRemoteStorageService.getFilters(@scope.projectId, 'tasks-custom-filters').then (userFilters) => userFilters[name] = filters @filterRemoteStorageService.storeFilters(@scope.projectId, userFilters, 'tasks-custom-filters').then(@.generateFilters) generateFilters: -> @.storeFilters(@params.pslug, @location.search(), "tasks-filters") urlfilters = @location.search() loadFilters = {} loadFilters.project = @scope.projectId loadFilters.milestone = @scope.sprintId loadFilters.q = urlfilters.q for key in @.filterCategories excludeKey = @.excludePrefix.concat(key) loadFilters[key] = urlfilters[key] loadFilters[excludeKey] = urlfilters[excludeKey] return @q.all([ @rs.tasks.filtersData(loadFilters), @filterRemoteStorageService.getFilters(@scope.projectId, 'tasks-custom-filters') ]).then (result) => data = result[0] customFiltersRaw = result[1] dataCollection = {} dataCollection.status = _.map data.statuses, (it) -> it.id = it.id.toString() return it dataCollection.tags = _.map data.tags, (it) -> it.id = it.name return it tagsWithAtLeastOneElement = _.filter dataCollection.tags, (tag) -> return tag.count > 0 dataCollection.assigned_to = _.map data.assigned_to, (it) -> if it.id it.id = it.id.toString() else it.id = "null" it.name = it.full_name || "Unassigned" return it dataCollection.role = _.map data.roles, (it) -> if it.id it.id = it.id.toString() else it.id = "null" it.name = it.name || "Unassigned" return it dataCollection.owner = _.map data.owners, (it) -> it.id = it.id.toString() it.name = it.full_name return it @.selectedFilters = [] for key in @.filterCategories excludeKey = @.excludePrefix.concat(key) if loadFilters[key] selected = @.formatSelectedFilters(key, dataCollection[key], loadFilters[key]) @.selectedFilters = @.selectedFilters.concat(selected) if loadFilters[excludeKey] selected = @.formatSelectedFilters(key, dataCollection[key], loadFilters[excludeKey], "exclude") @.selectedFilters = @.selectedFilters.concat(selected) @.filterQ = loadFilters.q @.filters = [ { title: @translate.instant("COMMON.FILTERS.CATEGORIES.STATUS"), dataType: "status", content: dataCollection.status }, { title: @translate.instant("COMMON.FILTERS.CATEGORIES.TAGS"), dataType: "tags", content: dataCollection.tags, hideEmpty: true, totalTaggedElements: tagsWithAtLeastOneElement.length }, { title: @translate.instant("COMMON.FILTERS.CATEGORIES.ASSIGNED_TO"), dataType: "assigned_to", content: dataCollection.assigned_to }, { title: @translate.instant("COMMON.FILTERS.CATEGORIES.ROLE"), dataType: "role", content: dataCollection.role }, { title: @translate.instant("COMMON.FILTERS.CATEGORIES.CREATED_BY"), dataType: "owner", content: dataCollection.owner } ] @.customFilters = [] _.forOwn customFiltersRaw, (value, key) => @.customFilters.push({id: key, name: key, filter: value}) _setMeta: -> prettyDate = @translate.instant("BACKLOG.SPRINTS.DATE") title = @translate.instant("TASKBOARD.PAGE_TITLE", { projectName: @scope.project.name sprintName: @scope.sprint.name }) description = @translate.instant("TASKBOARD.PAGE_DESCRIPTION", { projectName: @scope.project.name sprintName: @scope.sprint.name startDate: moment(@scope.sprint.estimated_start).format(prettyDate) endDate: moment(@scope.sprint.estimated_finish).format(prettyDate) completedPercentage: @scope.stats.completedPercentage or "0" completedPoints: @scope.stats.completedPointsSum or "--" totalPoints: @scope.stats.totalPointsSum or "--" openTasks: @scope.stats.openTasks or "--" totalTasks: @scope.stats.total_tasks or "--" }) @appMetaService.setAll(title, description) initializeEventHandlers: -> @scope.$on "taskform:bulk:success", (event, tasks) => @.refreshTagsColors().then () => @taskboardTasksService.add(tasks) @analytics.trackEvent("task", "create", "bulk create task on taskboard", 1) @scope.$on "taskform:new:success", (event, task) => @.refreshTagsColors().then () => @taskboardTasksService.add(task) @analytics.trackEvent("task", "create", "create task on taskboard", 1) @scope.$on "taskform:edit:success", (event, task) => @.refreshTagsColors().then () => @taskboardTasksService.replaceModel(task) @scope.$on "issueform:new:success", (event, issue) => @.refreshTagsColors().then () => @taskboardIssuesService.add(issue) @analytics.trackEvent("issue", "create", "create issue on taskboard", 1) @scope.$on "issueform:add:success", (event, issue) => @.refreshTagsColors().then () => @taskboardIssuesService.add(issue) @scope.$on "issueform:edit:success", (event, issue) => @.refreshTagsColors().then () => @taskboardIssuesService.replaceModel(issue) @scope.$on "taskboard:task:deleted", (event, task) => @.loadTasks() @scope.$on "taskboard:issue:deleted", (event, issue) => @.loadIssues() @scope.$on("taskboard:task:move", @.taskMove) @scope.$on("assigned-to:added", @.onAssignedToChanged) @scope.$on "taskboard:items:move", (event, itemsMoved) => if itemsMoved.uss @.firstLoad() else @.loadTasks() if itemsMoved.tasks @.loadIssues() if itemsMoved.issues onAssignedToChanged: (ctx, userid, model) -> if model.getName() == 'tasks' model.assigned_to = userid @taskboardTasksService.replaceModel(model) @repo.save(model).then => @.generateFilters() if @.isFilterDataTypeSelected('assigned_to') || @.isFilterDataTypeSelected('role') @.loadTasks() if model.getName() == 'issues' model.assigned_to = userid @taskboardIssuesService.replaceModel(model) @repo.save(model).then => @.generateFilters() if @.isFilterDataTypeSelected('assigned_to') || @.isFilterDataTypeSelected('role') @.loadIssues() initializeSubscription: -> routingKey = "changes.project.#{@scope.projectId}.tasks" @events.subscribe @scope, routingKey, debounceLeading(500, (message) => @.loadTaskboard()) routingKey = "changes.project.#{@scope.projectId}.issues" @events.subscribe @scope, routingKey, debounceLeading(500, (message) => @.loadIssues()) routingKey1 = "changes.project.#{@scope.projectId}.userstories" @events.subscribe @scope, routingKey1, (message) => @.refreshTagsColors() @.loadSprintStats() @.loadSprint() loadProject: -> return @rs.projects.get(@scope.projectId).then (project) => if not project.is_backlog_activated @errorHandlingService.permissionDenied() @scope.project = project # Not used at this momment @scope.pointsList = _.sortBy(project.points, "order") @scope.pointsById = groupBy(project.points, (e) -> e.id) @scope.roleById = groupBy(project.roles, (e) -> e.id) @scope.taskStatusList = _.sortBy(project.task_statuses, "order") @scope.usStatusList = _.sortBy(project.us_statuses, "order") @scope.usStatusById = groupBy(project.us_statuses, (e) -> e.id) @scope.issueStatusById = groupBy(project.issue_statuses, (e) -> e.id) @scope.$emit('project:loaded', project) @.fillUsersAndRoles(project.members, project.roles) return project loadSprintStats: -> return @rs.sprints.stats(@scope.projectId, @scope.sprintId).then (stats) => totalPointsSum =_.reduce(_.values(stats.total_points), ((res, n) -> res + n), 0) completedPointsSum = _.reduce(_.values(stats.completed_points), ((res, n) -> res + n), 0) remainingPointsSum = totalPointsSum - completedPointsSum remainingTasks = stats.total_tasks - stats.completed_tasks @scope.stats = stats @scope.stats.totalPointsSum = totalPointsSum @scope.stats.completedPointsSum = completedPointsSum @scope.stats.remainingPointsSum = remainingPointsSum @scope.stats.remainingTasks = remainingTasks if stats.totalPointsSum @scope.stats.completedPercentage = Math.round(100*stats.completedPointsSum/stats.totalPointsSum) else @scope.stats.completedPercentage = 0 @scope.stats.openTasks = stats.total_tasks - stats.completed_tasks return stats refreshTagsColors: -> return @rs.projects.tagsColors(@scope.projectId).then (tags_colors) => @scope.project.tags_colors = tags_colors._attrs loadSprint: -> return @rs.sprints.get(@scope.projectId, @scope.sprintId).then (sprint) => @scope.sprint = sprint @scope.userstories = _.sortBy(sprint.user_stories, "sprint_order") @taskboardTasksService.setUserstories(@scope.userstories) return sprint loadIssues: -> params = {} if @.zoomLevel > 1 params.include_attachments = 1 params = _.merge params, @location.search() return @rs.issues.listInProject(@scope.projectId, @scope.sprintId, params).then (issues) => @taskboardIssuesService.init(@scope.project, @scope.usersById, @scope.issueStatusById) @taskboardIssuesService.set(issues) @scope.taskBoardLoading = false loadTasks: -> params = {} if @.zoomLevel > 1 params.include_attachments = 1 params = _.merge params, @location.search() return @rs.tasks.list(@scope.projectId, @scope.sprintId, null, params).then (tasks) => @taskboardTasksService.init(@scope.project, @scope.usersById) @taskboardTasksService.set(tasks) loadTaskboard: -> return @q.all([ @.refreshTagsColors(), @.loadSprintStats(), @.loadSprint().then(=> @.loadTasks() @.loadIssues() ) ]) loadInitialData: -> params = { pslug: @params.pslug sslug: @params.sslug } promise = @repo.resolve(params).then (data) => @scope.projectId = data.project @scope.sprintId = data.milestone @.initializeSubscription() return data return promise.then(=> @.loadProject()) .then => @.generateFilters() return @.loadTaskboard().then(=> @.setRolePoints()) showPlaceHolder: (statusId, usId) -> if !@taskboardTasksService.tasksRaw.length if @scope.taskStatusList[0].id == statusId && (!@scope.userstories.length || @scope.userstories[0].id == usId) return true return false editTask: (id) -> task = @.taskboardTasksService.getTask(id) task = task.set('loading-edit', true) @taskboardTasksService.replace(task) @rs.tasks.getByRef(task.getIn(['model', 'project']), task.getIn(['model', 'ref'])) .then (editingTask) => @rs2.attachments.list("task", task.get('id'), task.getIn(['model', 'project'])) .then (attachments) => @rootscope.$broadcast("genericform:edit", { 'objType': 'task', 'obj': editingTask, 'project': @scope.project, 'sprintId': @scope.sprintId, 'attachments': attachments.toJS() }) task = task.set('loading-edit', false) @taskboardTasksService.replace(task) editIssue: (id) -> issue = @.taskboardIssuesService.getIssue(id) issue = issue.set('loading-edit', true) @rs.issues.getByRef(issue.getIn(['model', 'project']), issue.getIn(['model', 'ref'])) .then (editingIssue) => @rs2.attachments.list("issue", issue.get('id'), issue.getIn(['model', 'project'])) .then (attachments) => @rootscope.$broadcast("genericform:edit", { 'objType': 'issue', 'obj': editingIssue, 'project': @scope.project, 'sprintId': @scope.sprintId, 'attachments': attachments.toJS() }) issue = issue.set('loading-edit', false) deleteTask: (id) -> task = @.taskboardTasksService.getTask(id) task = task.set('loading-delete', true) @rs.tasks.getByRef(task.getIn(['model', 'project']), task.getIn(['model', 'ref'])) .then (deletingTask) => task = task.set('loading-delete', false) title = @translate.instant("TASK.TITLE_DELETE_ACTION") message = deletingTask.subject @confirm.askOnDelete(title, message).then (askResponse) => promise = @repo.remove(deletingTask) promise.then => @scope.$broadcast("taskboard:task:deleted") askResponse.finish() promise.then null, -> askResponse.finish(false) @confirm.notify("error") deleteIssue: (id) -> issue = @.taskboardIssuesService.getIssue(id) issue = issue.set('loading-delete', true) @rs.issues.getByRef(issue.getIn(['model', 'project']), issue.getIn(['model', 'ref'])) .then (deletingIssue) => issue = issue.set('loading-delete', false) title = @translate.instant("ISSUES.ACTION_DELETE") message = deletingIssue.subject @confirm.askOnDelete(title, message).then (askResponse) => promise = @repo.remove(deletingIssue) promise.then => @scope.$broadcast("taskboard:issue:deleted") askResponse.finish() promise.then null, -> askResponse.finish(false) @confirm.notify("error") removeIssueFromSprint: (id) -> issue = @.taskboardIssuesService.getIssue(id) issue = issue.set('loading-delete', true) @rs.issues.getByRef(issue.getIn(['model', 'project']), issue.getIn(['model', 'ref'])) .then (removingIssue) => issue = issue.set('loading-delete', false) title = @translate.instant("ISSUES.CONFIRM_DETACH_FROM_SPRINT.TITLE") message = @translate.instant("ISSUES.CONFIRM_DETACH_FROM_SPRINT.MESSAGE") message = @translate.instant( "ISSUES.CONFIRM_DETACH_FROM_SPRINT.MESSAGE", {sprintName: @scope.sprint.name} ) @confirm.ask(title, null, message).then (askResponse) => removingIssue.milestone = null promise = @repo.save(removingIssue) promise.then => @.taskboardIssuesService.remove(removingIssue) askResponse.finish() promise.then null, -> askResponse.finish(false) @confirm.notify("error") taskMove: (ctx, task, oldStatusId, usId, statusId, order) -> @scope.movingTask = true task = @taskboardTasksService.getTaskModel(task.get('id')) moveUpdateData = @taskboardTasksService.move(task.id, usId, statusId, order) params = { status__is_archived: false, include_attachments: true, } options = { headers: { "set-orders": JSON.stringify(moveUpdateData.set_orders) } } promise = @repo.save(task, true, params, options, true).then (result) => if result[0].user_story @.reloadUserStory(result[0].user_story) @scope.movingTask = false headers = result[1] if headers && headers['taiga-info-order-updated'] order = JSON.parse(headers['taiga-info-order-updated']) @taskboardTasksService.assignOrders(order) @.loadSprintStats() @.generateFilters() if @.isFilterDataTypeSelected('status') @.loadTasks() reloadUserStory: (userStoryId) -> @rs.userstories.get(@scope.project.id, userStoryId).then (us) => @scope.userstories = _.map(@scope.userstories, (x) -> if x.id == us.id then us else x) ## Template actions addNewTask: (type, us) -> switch type when "standard" then @rootscope.$broadcast("genericform:new", { 'objType': 'task', 'project': @scope.project, 'sprintId': @scope.sprintId, 'usId': us?.id }) when "bulk" then @rootscope.$broadcast("taskform:bulk", @scope.sprintId, us?.id) addNewIssue: (type, us) -> switch type when "standard" then @rootscope.$broadcast("genericform:new-or-existing", { objType: 'issue', project: @scope.project, sprintId: @scope.sprintId, relatedField: 'milestone', relatedObjectId: @scope.sprintId, targetName: @scope.sprint.name, }) when "standard" then @rootscope.$broadcast("taskform:new", @scope.sprintId, us?.id) when "bulk" then @rootscope.$broadcast("issueform:bulk", @scope.projectId, @scope.sprintId) toggleFold: (id, modelName) -> if modelName == 'issues' @taskboardIssuesService.toggleFold(id) else if modelName == 'tasks' @taskboardTasksService.toggleFold(id) changeTaskAssignedTo: (id) -> task = @taskboardTasksService.getTaskModel(id) @rootscope.$broadcast("assigned-to:add", task) changeIssueAssignedTo: (id) -> issue = @taskboardIssuesService.getIssueModel(id) @rootscope.$broadcast("assigned-to:add", issue) setRolePoints: () -> computableRoles = _.filter(@scope.project.roles, "computable") getRole = (roleId) => roleId = parseInt(roleId, 10) return _.find computableRoles, (role) -> role.id == roleId getPoint = (pointId) => poitnId = parseInt(pointId, 10) return _.find @scope.project.points, (point) -> point.id == pointId pointsByRole = _.reduce @scope.userstories, (result, us, key) => _.forOwn us.points, (pointId, roleId) -> role = getRole(roleId) point = getPoint(pointId) if !result[role.id] result[role.id] = role result[role.id].points = 0 result[role.id].points += point.value return result , {} @scope.pointsByRole = Object.keys(pointsByRole).map (key) -> return pointsByRole[key] module.controller("TaskboardController", TaskboardController) ############################################################################# ## TaskboardDirective ############################################################################# TaskboardDirective = ($rootscope) -> link = ($scope, $el, $attrs) -> $ctrl = $el.controller() $el.on "click", ".toggle-analytics-visibility", (event) -> event.preventDefault() target = angular.element(event.currentTarget) target.toggleClass('active') $rootscope.$broadcast("taskboard:graph:toggle-visibility") tableBodyDom = $el.find(".taskboard-table-body") tableBodyDom.on "scroll", (event) -> target = angular.element(event.currentTarget) tableHeaderDom = $el.find(".taskboard-table-header .taskboard-table-inner") tableHeaderDom.css("left", -1 * target.scrollLeft()) $scope.$on "$destroy", -> $el.off() return {link: link} module.directive("tgTaskboard", ["$rootScope", TaskboardDirective]) ############################################################################# ## Taskboard Squish Column Directive ############################################################################# TaskboardSquishColumnDirective = (rs) -> avatarWidth = 40 maxColumnWidth = 300 link = ($scope, $el, $attrs) -> $scope.$on "sprint:zoom0", () => recalculateTaskboardWidth() $scope.$on "sprint:task:moved", () => recalculateTaskboardWidth() $scope.$watch "usTasks", () -> if $scope.project $scope.statusesFolded = rs.tasks.getStatusColumnModes($scope.project.id) $scope.usFolded = rs.tasks.getUsRowModes($scope.project.id, $scope.sprintId) recalculateTaskboardWidth() $scope.foldStatus = (status) -> $scope.statusesFolded[status.id] = !!!$scope.statusesFolded[status.id] rs.tasks.storeStatusColumnModes($scope.projectId, $scope.statusesFolded) recalculateTaskboardWidth() $scope.foldUs = (rowId) -> $scope.usFolded[rowId] = !!!$scope.usFolded[rowId] rs.tasks.storeUsRowModes($scope.projectId, $scope.sprintId, $scope.usFolded) recalculateTaskboardWidth() getCeilWidth = (usId, statusId) => if usId tasks = $scope.usTasks.getIn([usId.toString(), statusId.toString()]).size else tasks = $scope.usTasks.getIn(['null', statusId.toString()]).size if $scope.statusesFolded[statusId] if tasks and $scope.usFolded[usId] tasksMatrixSize = Math.round(Math.sqrt(tasks)) width = avatarWidth * tasksMatrixSize else width = avatarWidth return width return 0 setStatusColumnWidth = (statusId, width) => column = $el.find(".squish-status-#{statusId}") if width column.css('max-width', width) else if $scope.ctrl.zoomLevel == '0' column.css("max-width", 148) else column.css("max-width", maxColumnWidth) refreshTaskboardTableWidth = () => columnWidths = [] columns = $el.find(".task-colum-name") columnWidths = _.map columns, (column) -> return $(column).outerWidth(true) totalWidth = _.reduce columnWidths, (total, width) -> return total + width $el.find('.taskboard-table-inner').css("width", totalWidth) issuesBoxWidth = $el.find('.issues-row .taskboard-row-title-box').outerWidth(true) $el.find('.issues-row').css("width", totalWidth - columnWidths.pop()) issueCardMaxWidth = if $scope.ctrl.zoomLevel == '0' then 128 else 280 $el.find('.issues-row .taskboard-cards-box .card').css("max-width", issueCardMaxWidth) recalculateStatusColumnWidth = (statusId) => #unassigned ceil statusFoldedWidth = getCeilWidth(null, statusId) _.forEach $scope.userstories, (us) -> width = getCeilWidth(us.id, statusId) statusFoldedWidth = width if width > statusFoldedWidth setStatusColumnWidth(statusId, statusFoldedWidth) recalculateTaskboardWidth = () => _.forEach $scope.taskStatusList, (status) -> recalculateStatusColumnWidth(status.id) refreshTaskboardTableWidth() return return {link: link} module.directive("tgTaskboardSquishColumn", ["$tgResources", TaskboardSquishColumnDirective])