### # Copyright (C) 2014 Andrey Antukh # Copyright (C) 2014 Jesús Espino Garcia # Copyright (C) 2014 David Barragán Merino # # 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/issues/list.coffee ### taiga = @.taiga mixOf = @.taiga.mixOf trim = @.taiga.trim toString = @.taiga.toString joinStr = @.taiga.joinStr groupBy = @.taiga.groupBy bindOnce = @.taiga.bindOnce debounceLeading = @.taiga.debounceLeading startswith = @.taiga.startswith module = angular.module("taigaIssues") ############################################################################# ## Issues Controller ############################################################################# class IssuesController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin) @.$inject = [ "$scope", "$rootScope", "$tgRepo", "$tgConfirm", "$tgResources", "$tgUrls", "$routeParams", "$q", "$tgLocation", "$appTitle", "$tgNavUrls", "$tgEvents", "$tgAnalytics", "tgLoader" ] constructor: (@scope, @rootscope, @repo, @confirm, @rs, @urls, @params, @q, @location, @appTitle, @navUrls, @events, @analytics, tgLoader) -> @scope.sectionName = "Issues" @scope.filters = {} if _.isEmpty(@location.search()) filters = @rs.issues.getFilters(@params.pslug) filters.page = 1 @location.search(filters) @location.replace() return promise = @.loadInitialData() # On Success promise.then => @appTitle.set("Issues - " + @scope.project.name) tgLoader.pageLoaded() # On Error promise.then null, (xhr) => if xhr and xhr.status == 404 @location.path(@navUrls.resolve("not-found")) @location.replace() return @q.reject(xhr) @scope.$on "issueform:new:success", => @analytics.trackEvent("issue", "create", "create issue on issues list", 1) @.loadIssues() @.loadFilters() initializeSubscription: -> routingKey = "changes.project.#{@scope.projectId}.issues" @events.subscribe @scope, routingKey, (message) => @.loadIssues() storeFilters: -> @rs.issues.storeFilters(@params.pslug, @location.search()) loadProject: -> return @rs.projects.get(@scope.projectId).then (project) => @scope.project = project @scope.$emit('project:loaded', project) @scope.issueStatusById = groupBy(project.issue_statuses, (x) -> x.id) @scope.issueStatusList = _.sortBy(project.issue_statuses, "order") @scope.severityById = groupBy(project.severities, (x) -> x.id) @scope.severityList = _.sortBy(project.severities, "order") @scope.priorityById = groupBy(project.priorities, (x) -> x.id) @scope.priorityList = _.sortBy(project.priorities, "order") @scope.issueTypes = _.sortBy(project.issue_types, "order") @scope.issueTypeById = groupBy(project.issue_types, (x) -> x.id) @scope.membersById = groupBy(project.memberships, (x) -> x.user) return project getUrlFilters: -> filters = _.pick(@location.search(), "page", "tags", "statuses", "types", "q", "severities", "priorities", "assignedTo", "createdBy", "orderBy") filters.page = 1 if not filters.page return filters getUrlFilter: (name) -> filters = _.pick(@location.search(), name) return filters[name] loadMyFilters: -> return @rs.issues.getMyFilters(@scope.projectId).then (filters) => return _.map filters, (value, key) => return {id: key, name: key, type: "myFilters", selected: false} removeNotExistingFiltersFromUrl: -> currentSearch = @location.search() urlfilters = @.getUrlFilters() for filterName, filterValue of urlfilters if filterName == "page" or filterName == "orderBy" or filterName == "q" continue if filterName == "tags" splittedValues = _.map("#{filterValue}".split(",")) else splittedValues = _.map("#{filterValue}".split(","), (x) -> if x == "null" then null else parseInt(x)) existingValues = _.intersection(splittedValues, _.map(@scope.filters[filterName], "id")) if splittedValues.length != existingValues.length @location.search(filterName, existingValues.join()) if currentSearch != @location.search() @location.replace() markSelectedFilters: (filters, urlfilters) -> # Build selected filters (from url) fast lookup data structure searchdata = {} for name, value of _.omit(urlfilters, "page", "orderBy") if not searchdata[name]? searchdata[name] = {} for val in "#{value}".split(",") searchdata[name][val] = true isSelected = (type, id) -> if searchdata[type]? and searchdata[type][id] return true return false for key, value of filters for obj in value obj.selected = if isSelected(obj.type, obj.id) then true else undefined loadFilters: -> urlfilters = @.getUrlFilters() if urlfilters.q @scope.filtersQ = urlfilters.q # Load My Filters promise = @.loadMyFilters().then (myFilters) => @scope.filters.myFilters = myFilters return myFilters # Load default filters data promise = promise.then => return @rs.issues.filtersData(@scope.projectId) # Format filters and set them on scope return promise.then (data) => usersFiltersFormat = (users, type, unknownOption) => reformatedUsers = _.map users, (t) => return { id: t[0], count: t[1], type: type name: if t[0] then @scope.usersById[t[0]].full_name_display else unknownOption } unknownItem = _.remove(reformatedUsers, (u) -> not u.id) reformatedUsers = _.sortBy(reformatedUsers, (u) -> u.name.toUpperCase()) if unknownItem.length > 0 reformatedUsers.unshift(unknownItem[0]) return reformatedUsers choicesFiltersFormat = (choices, type, byIdObject) => _.map choices, (t) -> return { id: t[0], name: byIdObject[t[0]].name, color: byIdObject[t[0]].color, count: t[1], type: type} tagsFilterFormat = (tags) => return _.map tags, (t) => return { id: t[0], name: t[0], color: @scope.project.tags_colors[t[0]], count: t[1], type: "tags" } # Build filters data structure @scope.filters.statuses = choicesFiltersFormat(data.statuses, "statuses", @scope.issueStatusById) @scope.filters.severities = choicesFiltersFormat(data.severities, "severities", @scope.severityById) @scope.filters.priorities = choicesFiltersFormat(data.priorities, "priorities", @scope.priorityById) @scope.filters.assignedTo = usersFiltersFormat(data.assigned_to, "assignedTo", "Unassigned") @scope.filters.createdBy = usersFiltersFormat(data.created_by, "createdBy", "Unknown") @scope.filters.types = choicesFiltersFormat(data.types, "types", @scope.issueTypeById) @scope.filters.tags = tagsFilterFormat(data.tags) @.removeNotExistingFiltersFromUrl() @.markSelectedFilters(@scope.filters, urlfilters) @rootscope.$broadcast("filters:loaded", @scope.filters) loadIssues: -> @scope.urlFilters = @.getUrlFilters() # Convert stored filters to http parameters # ready filters (the name difference exists # because of some automatic lookups and is # the simplest way todo it without adding # additional complexity to code. @scope.httpParams = {} for name, values of @scope.urlFilters if name == "severities" name = "severity" else if name == "orderBy" name = "order_by" else if name == "priorities" name = "priority" else if name == "assignedTo" name = "assigned_to" else if name == "createdBy" name = "owner" else if name == "statuses" name = "status" else if name == "types" name = "type" @scope.httpParams[name] = values return @rs.issues.list(@scope.projectId, @scope.httpParams).then (data) => @scope.issues = data.models @scope.page = data.current @scope.count = data.count @scope.paginatedBy = data.paginatedBy return data loadInitialData: -> promise = @repo.resolve({pslug: @params.pslug}).then (data) => @scope.projectId = data.project @.initializeSubscription() return data return promise.then(=> @.loadProject()) .then(=> @.loadUsersAndRoles()) .then(=> @q.all([@.loadFilters(), @.loadIssues()])) saveCurrentFiltersTo: (newFilter) -> deferred = @q.defer() @rs.issues.getMyFilters(@scope.projectId).then (filters) => filters[newFilter] = @location.search() @rs.issues.storeMyFilters(@scope.projectId, filters).then => deferred.resolve() return deferred.promise deleteMyFilter: (filter) -> deferred = @q.defer() @rs.issues.getMyFilters(@scope.projectId).then (filters) => delete filters[filter] @rs.issues.storeMyFilters(@scope.projectId, filters).then => deferred.resolve() return deferred.promise # Functions used from templates addNewIssue: -> @rootscope.$broadcast("issueform:new", @scope.project) addIssuesInBulk: -> @rootscope.$broadcast("issueform:bulk", @scope.projectId) module.controller("IssuesController", IssuesController) ############################################################################# ## Issues Directive ############################################################################# paginatorTemplate = """
    <% if (showPrevious) { %> <% } %> <% _.each(pages, function(item) { %>
  • <% if (item.type === "page") { %> <%= item.num %> <% } else if (item.type === "page-active") { %> <%= item.num %> <% } else { %> ... <% } %>
  • <% }); %> <% if (showNext) { %> <% } %>
""" IssuesDirective = ($log, $location) -> ## Issues Pagination template = _.template(paginatorTemplate) linkPagination = ($scope, $el, $attrs, $ctrl) -> # Constants afterCurrent = 2 beforeCurrent = 4 atBegin = 2 atEnd = 2 $pagEl = $el.find(".issues-paginator") getNumPages = -> numPages = $scope.count / $scope.paginatedBy if parseInt(numPages, 10) < numPages numPages = parseInt(numPages, 10) + 1 else numPages = parseInt(numPages, 10) return numPages renderPagination = -> numPages = getNumPages() if numPages <= 1 $pagEl.hide() return $pagEl.show() pages = [] options = {} options.pages = pages options.showPrevious = ($scope.page > 1) options.showNext = not ($scope.page == numPages) cpage = $scope.page for i in [1..numPages] if i == (cpage + afterCurrent) and numPages > (cpage + afterCurrent + atEnd) pages.push({classes: "dots", type: "dots"}) else if i == (cpage - beforeCurrent) and cpage > (atBegin + beforeCurrent) pages.push({classes: "dots", type: "dots"}) else if i > (cpage + afterCurrent) and i <= (numPages - atEnd) else if i < (cpage - beforeCurrent) and i > atBegin else if i == cpage pages.push({classes: "active", num: i, type: "page-active"}) else pages.push({classes: "page", num: i, type: "page"}) $pagEl.html(template(options)) $scope.$watch "issues", (value) -> # Do nothing if value is not logical true return if not value renderPagination() $el.on "click", ".issues-paginator a.next", (event) -> event.preventDefault() $scope.$apply -> $ctrl.selectFilter("page", $scope.page + 1) $ctrl.loadIssues() $el.on "click", ".issues-paginator a.previous", (event) -> event.preventDefault() $scope.$apply -> $ctrl.selectFilter("page", $scope.page - 1) $ctrl.loadIssues() $el.on "click", ".issues-paginator li.page > a", (event) -> event.preventDefault() target = angular.element(event.currentTarget) pagenum = target.data("pagenum") $scope.$apply -> $ctrl.selectFilter("page", pagenum) $ctrl.loadIssues() ## Issues Filters linkOrdering = ($scope, $el, $attrs, $ctrl) -> # Draw the arrow the first time currentOrder = $ctrl.getUrlFilter("orderBy") or "created_date" if currentOrder icon = if startswith(currentOrder, "-") then "icon-caret-up" else "icon-caret-down" colHeadElement = $el.find(".row.title > div[data-fieldname='#{trim(currentOrder, "-")}']") colHeadElement.html("#{colHeadElement.html()}") $el.on "click", ".row.title > div", (event) -> target = angular.element(event.currentTarget) currentOrder = $ctrl.getUrlFilter("orderBy") newOrder = target.data("fieldname") finalOrder = if currentOrder == newOrder then "-#{newOrder}" else newOrder $scope.$apply -> $ctrl.replaceFilter("orderBy", finalOrder) $ctrl.storeFilters() $ctrl.loadIssues().then -> # Update the arrow $el.find(".row.title > div > span.icon").remove() icon = if startswith(finalOrder, "-") then "icon-caret-up" else "icon-caret-down" target.html("#{target.html()}") ## Issues Link link = ($scope, $el, $attrs) -> $ctrl = $el.controller() linkOrdering($scope, $el, $attrs, $ctrl) linkPagination($scope, $el, $attrs, $ctrl) $scope.$on "$destroy", -> $el.off() return {link:link} module.directive("tgIssues", ["$log", "$tgLocation", IssuesDirective]) ############################################################################# ## Issues Filters Directive ############################################################################# IssuesFiltersDirective = ($log, $location, $rs, $confirm, $loading) -> template = _.template(""" <% _.each(filters, function(f) { %> <% if (!f.selected) { %> style="border-left: 3px solid <%- f.color %>;"<% } %>> <%- f.name %> <% if (f.count){ %> <%- f.count %> <% } %> <% if (f.type == "myFilters"){ %> <% } %> <% } %> <% }) %> """) templateSelected = _.template(""" <% _.each(filters, function(f) { %> style="border-left: 3px solid <%= f.color %>;"<% } %>> <%- f.name %> <% }) %> """) link = ($scope, $el, $attrs) -> $ctrl = $el.closest(".wrapper").controller() selectedFilters = [] showFilters = (title, type) -> $el.find(".filters-cats").hide() $el.find(".filter-list").show() $el.find("h2.breadcrumb").removeClass("hidden") $el.find("h2 a.subfilter span.title").html(title) $el.find("h2 a.subfilter span.title").prop("data-type", type) showCategories = -> $el.find(".filters-cats").show() $el.find(".filter-list").hide() $el.find("h2.breadcrumb").addClass("hidden") initializeSelectedFilters = (filters) -> selectedFilters = [] for name, values of filters for val in values selectedFilters.push(val) if val.selected renderSelectedFilters(selectedFilters) renderSelectedFilters = (selectedFilters) -> html = templateSelected({filters:selectedFilters}) $el.find(".filters-applied").html(html) if selectedFilters.length > 0 $el.find(".save-filters").show() else $el.find(".save-filters").hide() renderFilters = (filters) -> html = template({filters:filters}) $el.find(".filter-list").html(html) toggleFilterSelection = (type, id) -> if type == "myFilters" $rs.issues.getMyFilters($scope.projectId).then (data) -> myFilters = data filters = myFilters[id] filters.page = 1 $ctrl.replaceAllFilters(filters) $ctrl.storeFilters() $ctrl.loadIssues() $ctrl.markSelectedFilters($scope.filters, filters) initializeSelectedFilters($scope.filters) return null filters = $scope.filters[type] filter = _.find(filters, {id:id}) filter.selected = (not filter.selected) # Convert id to null as string for properly # put null value on url parameters id = "null" if id is null if filter.selected selectedFilters.push(filter) $scope.$apply -> $ctrl.selectFilter(type, id) $ctrl.selectFilter("page", 1) $ctrl.storeFilters() $ctrl.loadIssues() else selectedFilters = _.reject(selectedFilters, filter) $scope.$apply -> $ctrl.unselectFilter(type, id) $ctrl.selectFilter("page", 1) $ctrl.storeFilters() $ctrl.loadIssues() renderSelectedFilters(selectedFilters) currentFiltersType = $el.find("h2 a.subfilter span.title").prop('data-type') if type == currentFiltersType renderFilters(_.reject(filters, "selected")) # Angular Watchers $scope.$on "filters:loaded", (ctx, filters) -> initializeSelectedFilters(filters) selectQFilter = debounceLeading 100, (value) -> return if value is undefined if value.length == 0 $ctrl.replaceFilter("q", null) $ctrl.storeFilters() else $ctrl.replaceFilter("q", value) $ctrl.storeFilters() $ctrl.loadIssues() $scope.$watch("filtersQ", selectQFilter) # Dom Event Handlers $el.on "click", ".filters-cats > ul > li > a", (event) -> event.preventDefault() target = angular.element(event.currentTarget) tags = $scope.filters[target.data("type")] renderFilters(_.reject(tags, "selected")) showFilters(target.attr("title"), target.data("type")) $el.on "click", ".filters-inner > .filters-step-cat > .breadcrumb > .back", (event) -> event.preventDefault() showCategories($el) $el.on "click", ".filters-applied a", (event) -> event.preventDefault() target = angular.element(event.currentTarget) id = target.data("id") or null type = target.data("type") toggleFilterSelection(type, id) $el.on "click", ".filter-list .single-filter", (event) -> event.preventDefault() target = angular.element(event.currentTarget) target.toggleClass("active") id = target.data("id") or null type = target.data("type") # A saved filter can't be active if type == "myFilters" target.removeClass("active") toggleFilterSelection(type, id) $el.on "click", ".filter-list .single-filter .icon-delete", (event) -> event.preventDefault() event.stopPropagation() target = angular.element(event.currentTarget) customFilterName = target.parent().data('id') title = "Delete custom filter" # TODO: i18n subtitle = "the custom filter '#{customFilterName}'" # TODO: i18n $confirm.ask(title, subtitle).then (finish) -> promise = $ctrl.deleteMyFilter(customFilterName) promise.then -> promise = $ctrl.loadMyFilters() promise.then (filters) -> finish() $scope.filters.myFilters = filters renderFilters($scope.filters.myFilters) promise.then null, -> finish() promise.then null, -> finish(false) $confirm.notify("error") $el.on "click", ".save-filters", (event) -> event.preventDefault() renderFilters($scope.filters["myFilters"]) showFilters("My filters", "myFilters") $el.find('.save-filters').hide() $el.find('.my-filter-name').show() $el.find('.my-filter-name').focus() $el.on "keyup", ".new .my-filter-name", (event) -> event.preventDefault() if event.keyCode == 13 target = angular.element(event.currentTarget) newFilter = target.val() $loading.start($el.find(".new")) promise = $ctrl.saveCurrentFiltersTo(newFilter) promise.then -> loadPromise = $ctrl.loadMyFilters() loadPromise.then (filters) -> $loading.finish($el.find(".new")) $scope.filters.myFilters = filters currentfilterstype = $el.find("h2 a.subfilter span.title").prop('data-type') if currentfilterstype == "myFilters" renderFilters($scope.filters.myFilters) $el.find('.my-filter-name').hide() $el.find('.save-filters').show() loadPromise.then null, -> $loading.finish($el.find(".new")) $confirm.notify("error", "Error loading custom filters") promise.then null, -> $loading.finish($el.find(".new")) $el.find(".my-filter-name").val(newFilter).focus().select() $confirm.notify("error", "Filter not saved") else if event.keyCode == 27 $el.find('.my-filter-name').val('') $el.find('.my-filter-name').hide() $el.find('.save-filters').show() return {link:link} module.directive("tgIssuesFilters", ["$log", "$tgLocation", "$tgResources", "$tgConfirm", "$tgLoading", IssuesFiltersDirective]) ############################################################################# ## Issue status Directive (popover for change status) ############################################################################# IssueStatusInlineEditionDirective = ($repo, popoverService) -> ### Print the status of an Issue and a popover to change it. - tg-issue-status-inline-edition: The issue Example: div.status(tg-issue-status-inline-edition="issue") a.issue-status(href="") NOTE: This directive need 'issueStatusById' and 'project'. ### selectionTemplate = _.template(""" """) updateIssueStatus = ($el, issue, issueStatusById) -> issueStatusDomParent = $el.find(".issue-status") issueStatusDom = $el.find(".issue-status .issue-status-bind") status = issueStatusById[issue.status] if status issueStatusDom.text(status.name) issueStatusDom.prop("title", status.name) issueStatusDomParent.css('color', status.color) link = ($scope, $el, $attrs) -> $ctrl = $el.controller() issue = $scope.$eval($attrs.tgIssueStatusInlineEdition) $el.on "click", ".issue-status", (event) -> event.preventDefault() event.stopPropagation() $el.find(".pop-status").popover().open() $el.on "click", ".status", (event) -> event.preventDefault() event.stopPropagation() target = angular.element(event.currentTarget) issue.status = target.data("status-id") $el.find(".pop-status").popover().close() updateIssueStatus($el, issue, $scope.issueStatusById) $scope.$apply () -> $repo.save(issue).then taiga.bindOnce $scope, "project", (project) -> $el.append(selectionTemplate({ 'statuses': project.issue_statuses })) updateIssueStatus($el, issue, $scope.issueStatusById) # If the user has not enough permissions the click events are unbinded if project.my_permissions.indexOf("modify_issue") == -1 $el.unbind("click") $el.find("a").addClass("not-clickable") $scope.$watch $attrs.tgIssueStatusInlineEdition, (val) => updateIssueStatus($el, val, $scope.issueStatusById) $scope.$on "$destroy", -> $el.off() return {link: link} module.directive("tgIssueStatusInlineEdition", ["$tgRepo", IssueStatusInlineEditionDirective]) ############################################################################# ## Issue assigned to Directive ############################################################################# IssueAssignedToInlineEditionDirective = ($repo, $rootscope, popoverService) -> template = _.template(""" <%- name %>
<%- name %>
""") link = ($scope, $el, $attrs) -> updateIssue = (issue) -> ctx = {name: "Unassigned", imgurl: "/images/unnamed.png"} member = $scope.usersById[issue.assigned_to] if member ctx.imgurl = member.photo ctx.name = member.full_name_display $el.find(".avatar").html(template(ctx)) $el.find(".issue-assignedto").attr('title', ctx.name) $ctrl = $el.controller() issue = $scope.$eval($attrs.tgIssueAssignedToInlineEdition) updateIssue(issue) $el.on "click", ".issue-assignedto", (event) -> $rootscope.$broadcast("assigned-to:add", issue) taiga.bindOnce $scope, "project", (project) -> # If the user has not enough permissions the click events are unbinded if project.my_permissions.indexOf("modify_issue") == -1 $el.unbind("click") $el.find("a").addClass("not-clickable") $scope.$on "assigned-to:added", (ctx, userId, updatedIssue) => if updatedIssue.id == issue.id updatedIssue.assigned_to = userId $repo.save(updatedIssue) updateIssue(updatedIssue) $scope.$watch $attrs.tgIssueAssignedToInlineEdition, (val) => updateIssue(val) $scope.$on "$destroy", -> $el.off() return {link: link} module.directive("tgIssueAssignedToInlineEdition", ["$tgRepo", "$rootScope", IssueAssignedToInlineEditionDirective])