From 59bf55fc30336e784dc18e01a38865b9f640f4c1 Mon Sep 17 00:00:00 2001 From: Juanfran Date: Fri, 3 Jun 2016 08:20:35 +0200 Subject: [PATCH] cards & filters ui refactor --- CHANGELOG.md | 4 + app/coffee/modules/backlog/filters.coffee | 185 ----- app/coffee/modules/backlog/main.coffee | 417 ++++------- app/coffee/modules/backlog/sortable.coffee | 26 +- app/coffee/modules/base/repository.coffee | 17 +- app/coffee/modules/common/lightboxes.coffee | 28 +- app/coffee/modules/controllerMixins.coffee | 175 +++++ app/coffee/modules/issues/list.coffee | 656 ++++++------------ .../modules/kanban/kanban-usertories.coffee | 189 +++++ app/coffee/modules/kanban/main.coffee | 424 ++++------- app/coffee/modules/kanban/sortable.coffee | 12 +- app/coffee/modules/resources.coffee | 4 +- app/coffee/modules/resources/issues.coffee | 49 -- app/coffee/modules/resources/kanban.coffee | 10 - app/coffee/modules/resources/tasks.coffee | 12 +- .../modules/resources/userstories.coffee | 14 +- .../modules/taskboard/lightboxes.coffee | 19 +- app/coffee/modules/taskboard/main.coffee | 430 +++++++----- app/coffee/modules/taskboard/sortable.coffee | 30 +- .../modules/taskboard/taskboard-tasks.coffee | 173 +++++ app/coffee/utils.coffee | 2 +- app/locales/taiga/locale-en.json | 53 +- .../board-zoom/board-zoom.directive.coffee | 29 + .../components/board-zoom/board-zoom.jade | 9 + .../components/board-zoom/board-zoom.scss | 108 +++ .../card-slideshow.controller.coffee | 38 + .../card-slideshow.directive.coffee | 33 + .../card-slideshow/card-slideshow.jade | 18 + .../card/card-templates/card-completion.jade | 4 + .../card/card-templates/card-data.jade | 21 + .../card/card-templates/card-owner.jade | 43 ++ .../card/card-templates/card-tags.jade | 7 + .../card/card-templates/card-tasks.jade | 7 + .../card/card-templates/card-title.jade | 9 + .../card/card-templates/card-unfold.jade | 6 + .../components/card/card.controller.coffee | 82 +++ .../card/card.controller.spec.coffee | 142 ++++ .../components/card/card.directive.coffee | 43 ++ app/modules/components/card/card.jade | 16 + app/modules/components/card/card.scss | 326 +++++++++ .../filter/filter-remote.service.coffee | 68 ++ .../filter/filter-slide-down.directive.coffee | 45 ++ .../filter/filter.controller.coffee | 70 ++ .../filter/filter.controller.spec.coffee | 87 +++ .../components/filter/filter.directive.coffee | 44 ++ app/modules/components/filter/filter.jade | 110 +++ app/modules/components/filter/filter.scss | 150 ++++ .../joy-ride/joy-ride.service.coffee | 2 +- .../kanban-board-zoom.directive.coffee | 69 ++ .../taskboard-zoom.directive.coffee | 62 ++ app/partials/backlog/backlog.jade | 28 +- app/partials/backlog/filter-selected.jade | 9 - app/partials/backlog/filters.jade | 17 - .../includes/components/taskboard-task.jade | 18 - .../includes/modules/backlog-filters.jade | 36 - .../includes/modules/issues-filters.jade | 84 --- .../includes/modules/issues-table.jade | 24 +- .../includes/modules/kanban-table.jade | 53 +- .../includes/modules/taskboard-table.jade | 87 ++- .../issue/issues-filters-selected.jade | 9 - app/partials/issue/issues-filters.jade | 21 - app/partials/issue/issues.jade | 16 +- app/partials/kanban/kanban-task.jade | 33 - app/partials/kanban/kanban.jade | 30 +- app/partials/taskboard/taskboard.jade | 29 +- app/styles/components/buttons.scss | 11 + app/styles/components/filter.scss | 50 -- app/styles/components/kanban-task.scss | 224 ------ app/styles/components/taskboard-task.scss | 140 ---- app/styles/core/base.scss | 15 - app/styles/dependencies/helpers.scss | 2 +- app/styles/layout/backlog.scss | 21 + app/styles/layout/issues.scss | 6 +- app/styles/layout/kanban.scss | 10 + app/styles/layout/taskboard.scss | 7 + .../modules/backlog/taskboard-table.scss | 111 ++- app/styles/modules/filters/filters.scss | 114 --- app/styles/modules/kanban/kanban-table.scss | 30 +- app/styles/shame/shame.scss | 10 +- app/svg/sprite.svg | 9 + e2e/helpers/filters-helper.js | 81 +++ e2e/helpers/issues-helper.js | 54 -- e2e/helpers/kanban-helper.js | 34 +- e2e/helpers/taskboard-helper.js | 32 +- e2e/shared/filters.js | 76 ++ e2e/suites/backlog.e2e.js | 144 +--- e2e/suites/issues/issues.e2e.js | 195 +----- e2e/suites/kanban.e2e.js | 26 +- e2e/suites/tasks/taskboard.e2e.js | 28 +- gulpfile.js | 6 +- 90 files changed, 3695 insertions(+), 2812 deletions(-) delete mode 100644 app/coffee/modules/backlog/filters.coffee create mode 100644 app/coffee/modules/kanban/kanban-usertories.coffee create mode 100644 app/coffee/modules/taskboard/taskboard-tasks.coffee create mode 100644 app/modules/components/board-zoom/board-zoom.directive.coffee create mode 100644 app/modules/components/board-zoom/board-zoom.jade create mode 100644 app/modules/components/board-zoom/board-zoom.scss create mode 100644 app/modules/components/card-slideshow/card-slideshow.controller.coffee create mode 100644 app/modules/components/card-slideshow/card-slideshow.directive.coffee create mode 100644 app/modules/components/card-slideshow/card-slideshow.jade create mode 100644 app/modules/components/card/card-templates/card-completion.jade create mode 100644 app/modules/components/card/card-templates/card-data.jade create mode 100644 app/modules/components/card/card-templates/card-owner.jade create mode 100644 app/modules/components/card/card-templates/card-tags.jade create mode 100644 app/modules/components/card/card-templates/card-tasks.jade create mode 100644 app/modules/components/card/card-templates/card-title.jade create mode 100644 app/modules/components/card/card-templates/card-unfold.jade create mode 100644 app/modules/components/card/card.controller.coffee create mode 100644 app/modules/components/card/card.controller.spec.coffee create mode 100644 app/modules/components/card/card.directive.coffee create mode 100644 app/modules/components/card/card.jade create mode 100644 app/modules/components/card/card.scss create mode 100644 app/modules/components/filter/filter-remote.service.coffee create mode 100644 app/modules/components/filter/filter-slide-down.directive.coffee create mode 100644 app/modules/components/filter/filter.controller.coffee create mode 100644 app/modules/components/filter/filter.controller.spec.coffee create mode 100644 app/modules/components/filter/filter.directive.coffee create mode 100644 app/modules/components/filter/filter.jade create mode 100644 app/modules/components/filter/filter.scss create mode 100644 app/modules/components/kanban-board-zoom/kanban-board-zoom.directive.coffee create mode 100644 app/modules/components/taskboard-zoom/taskboard-zoom.directive.coffee delete mode 100644 app/partials/backlog/filter-selected.jade delete mode 100644 app/partials/backlog/filters.jade delete mode 100644 app/partials/includes/components/taskboard-task.jade delete mode 100644 app/partials/includes/modules/backlog-filters.jade delete mode 100644 app/partials/includes/modules/issues-filters.jade delete mode 100644 app/partials/issue/issues-filters-selected.jade delete mode 100644 app/partials/issue/issues-filters.jade delete mode 100644 app/partials/kanban/kanban-task.jade delete mode 100644 app/styles/components/filter.scss delete mode 100644 app/styles/components/kanban-task.scss delete mode 100644 app/styles/components/taskboard-task.scss delete mode 100644 app/styles/modules/filters/filters.scss create mode 100644 e2e/helpers/filters-helper.js create mode 100644 e2e/shared/filters.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0551a4ca..2e970fec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ - Add Wiki history - Third party integrations: - Included gogs as builtin integration. +- Filters refactor +- Cards ui refactor with zoom +- Kanban filters +- Taskboard filters ### Misc - Lots of small and not so small bugfixes. diff --git a/app/coffee/modules/backlog/filters.coffee b/app/coffee/modules/backlog/filters.coffee deleted file mode 100644 index 39a0dfa9..00000000 --- a/app/coffee/modules/backlog/filters.coffee +++ /dev/null @@ -1,185 +0,0 @@ -### -# Copyright (C) 2014-2016 Andrey Antukh -# Copyright (C) 2014-2016 Jesús Espino Garcia -# Copyright (C) 2014-2016 David Barragán Merino -# Copyright (C) 2014-2016 Alejandro Alonso -# Copyright (C) 2014-2016 Juan Francisco Alcántara -# Copyright (C) 2014-2016 Xavi Julian -# -# 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/backlog/main.coffee -### - -taiga = @.taiga - -mixOf = @.taiga.mixOf -toggleText = @.taiga.toggleText -scopeDefer = @.taiga.scopeDefer -bindOnce = @.taiga.bindOnce -groupBy = @.taiga.groupBy -debounceLeading = @.taiga.debounceLeading - - -module = angular.module("taigaBacklog") - -############################################################################# -## Issues Filters Directive -############################################################################# - -BacklogFiltersDirective = ($q, $log, $location, $template, $compile) -> - template = $template.get("backlog/filters.html", true) - templateSelected = $template.get("backlog/filter-selected.html", true) - - link = ($scope, $el, $attrs) -> - currentFiltersType = '' - - $ctrl = $el.closest(".wrapper").controller() - selectedFilters = [] - - showFilters = (title, type) -> - $el.find(".filters-cats").hide() - $el.find(".filter-list").removeClass("hidden") - $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) - - currentFiltersType = getFiltersType() - - showCategories = -> - $el.find(".filters-cats").show() - $el.find(".filter-list").addClass("hidden") - $el.find("h2.breadcrumb").addClass("hidden") - - initializeSelectedFilters = () -> - showCategories() - selectedFilters = [] - - for name, values of $scope.filters - for val in values - selectedFilters.push(val) if val.selected - - renderSelectedFilters() - - renderSelectedFilters = -> - _.map selectedFilters, (f) => - if f.color - f.style = "border-left: 3px solid #{f.color}" - - html = templateSelected({filters: selectedFilters}) - html = $compile(html)($scope) - - $el.find(".filters-applied").html(html) - - renderFilters = (filters) -> - _.map filters, (f) => - if f.color - f.style = "border-left: 3px solid #{f.color}" - - html = template({filters:filters}) - html = $compile(html)($scope) - $el.find(".filter-list").html(html) - - getFiltersType = () -> - return $el.find("h2 a.subfilter span.title").prop('data-type') - - reloadUserstories = () -> - currentFiltersType = getFiltersType() - - $q.all([$ctrl.loadUserstories(true), $ctrl.generateFilters()]).then () -> - currentFilters = $scope.filters[currentFiltersType] - renderFilters(_.reject(currentFilters, "selected")) - - toggleFilterSelection = (type, id) -> - currentFiltersType = getFiltersType() - - filters = $scope.filters[type] - filter = _.find(filters, {id: id}) - filter.selected = (not filter.selected) - - if filter.selected - selectedFilters.push(filter) - $scope.$apply -> - $ctrl.selectFilter(type, id) - else - selectedFilters = _.reject selectedFilters, (selected) -> - return filter.type == selected.type && filter.id == selected.id - - $ctrl.unselectFilter(type, id) - - renderSelectedFilters(selectedFilters) - - if type == currentFiltersType - renderFilters(_.reject(filters, "selected")) - - reloadUserstories() - - selectQFilter = debounceLeading 100, (value) -> - return if value is undefined - - if value.length == 0 - $ctrl.replaceFilter("q", null) - else - $ctrl.replaceFilter("q", value) - - reloadUserstories() - - $scope.$watch("filtersQ", selectQFilter) - - ## Angular Watchers - $scope.$on "backlog:loaded", (ctx) -> - initializeSelectedFilters() - - $scope.$on "filters:update", (ctx) -> - $ctrl.generateFilters().then () -> - filters = $scope.filters[currentFiltersType] - - if currentFiltersType - renderFilters(_.reject(filters, "selected")) - - ## 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.on "click", ".remove-filter", (event) -> - event.preventDefault() - target = angular.element(event.currentTarget).parent() - id = target.data("id") - type = target.data("type") - toggleFilterSelection(type, id) - - $el.on "click", ".filter-list .single-filter", (event) -> - event.preventDefault() - target = angular.element(event.currentTarget) - if target.hasClass("active") - target.removeClass("active") - else - target.addClass("active") - - id = target.data("id") - type = target.data("type") - toggleFilterSelection(type, id) - - return {link:link} - -module.directive("tgBacklogFilters", ["$q", "$log", "$tgLocation", "$tgTemplate", "$compile", BacklogFiltersDirective]) diff --git a/app/coffee/modules/backlog/main.coffee b/app/coffee/modules/backlog/main.coffee index ea9a256a..a9819327 100644 --- a/app/coffee/modules/backlog/main.coffee +++ b/app/coffee/modules/backlog/main.coffee @@ -39,7 +39,7 @@ module = angular.module("taigaBacklog") ## Backlog Controller ############################################################################# -class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin) +class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin, taiga.UsFiltersMixin) @.$inject = [ "$scope", "$rootScope", @@ -57,18 +57,30 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F "$tgLoading", "tgResources", "$tgQueueModelTransformation", - "tgErrorHandlingService" + "tgErrorHandlingService", + "$tgStorage", + "tgFilterRemoteStorageService" ] + storeCustomFiltersName: 'backlog-custom-filters' + storeFiltersName: 'backlog-filters' + backlogOrder: {} + milestonesOrder: {} + constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @location, @appMetaService, @navUrls, - @events, @analytics, @translate, @loading, @rs2, @modelTransform, @errorHandlingService) -> + @events, @analytics, @translate, @loading, @rs2, @modelTransform, @errorHandlingService, @storage, @filterRemoteStorageService) -> bindMethods(@) + @.backlogOrder = {} + @.milestonesOrder = {} + @.page = 1 @.disablePagination = false @.firstLoadComplete = false @scope.userstories = [] + return if @.applyStoredFilters(@params.pslug, "backlog-filters") + @scope.sectionName = @translate.instant("BACKLOG.SECTION_NAME") @showTags = false @activeFilters = false @@ -97,6 +109,9 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F # On Error promise.then null, @.onInitialDataError.bind(@) + filtersReloadContent: () -> + @.loadUserstories(true) + initializeEventHandlers: -> @scope.$on "usform:bulk:success", => @.loadUserstories(true) @@ -175,6 +190,12 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F @scope.showGraphPlaceholder = !(stats.total_points? && stats.total_milestones?) return stats + setMilestonesOrder: (sprints) -> + for sprint in sprints + @.milestonesOrder[sprint.id] = {} + for it in sprint.user_stories + @.milestonesOrder[sprint.id][it.id] = it.sprint_order + unloadClosedSprints: -> @scope.$apply => @scope.closedSprints = [] @@ -185,6 +206,8 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F return @rs.sprints.list(@scope.projectId, params).then (result) => sprints = result.milestones + @.setMilestonesOrder(sprints) + @scope.totalClosedMilestones = result.closed # NOTE: Fix order of USs because the filter orderBy does not work propertly in partials files @@ -200,6 +223,8 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F return @rs.sprints.list(@scope.projectId, params).then (result) => sprints = result.milestones + @.setMilestonesOrder(sprints) + @scope.totalMilestones = sprints @scope.totalClosedMilestones = result.closed @scope.totalOpenMilestones = result.open @@ -221,47 +246,6 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F return sprints - restoreFilters: -> - selectedTags = @scope.oldSelectedTags - selectedStatuses = @scope.oldSelectedStatuses - - return if !selectedStatuses and !selectedStatuses - - @scope.filtersQ = @scope.filtersQOld - - @.replaceFilter("q", @scope.filtersQ) - - _.each [selectedTags, selectedStatuses], (filterGrp) => - _.each filterGrp, (item) => - filters = @scope.filters[item.type] - filter = _.find(filters, {id: item.id}) - filter.selected = true - - @.selectFilter(item.type, item.id) - - @.loadUserstories() - - resetFilters: -> - selectedTags = _.filter(@scope.filters.tags, "selected") - selectedStatuses = _.filter(@scope.filters.status, "selected") - - @scope.oldSelectedTags = selectedTags - @scope.oldSelectedStatuses = selectedStatuses - - @scope.filtersQOld = @scope.filtersQ - @scope.filtersQ = undefined - @.replaceFilter("q", @scope.filtersQ) - - _.each [selectedTags, selectedStatuses], (filterGrp) => - _.each filterGrp, (item) => - filters = @scope.filters[item.type] - filter = _.find(filters, {id: item.id}) - filter.selected = false - - @.unselectFilter(item.type, item.id) - - @.loadUserstories() - loadAllPaginatedUserstories: () -> page = @.page @@ -273,15 +257,15 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F @.loadingUserstories = true @.disablePagination = true - @scope.httpParams = @.getUrlFilters() - @rs.userstories.storeQueryParams(@scope.projectId, @scope.httpParams) + params = _.clone(@location.search()) + @rs.userstories.storeQueryParams(@scope.projectId, params) if resetPagination @.page = 1 - @scope.httpParams.page = @.page + params.page = @.page - promise = @rs.userstories.listUnassigned(@scope.projectId, @scope.httpParams, pageSize) + promise = @rs.userstories.listUnassigned(@scope.projectId, params, pageSize) return promise.then (result) => userstories = result[0] @@ -293,7 +277,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")) - @.setSearchDataFilters() + for it in @scope.userstories + @.backlogOrder[it.id] = it.backlog_order @.loadingUserstories = false @@ -354,242 +339,142 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F return items + # --move us api behavior-- + # if your are moving multiples USs you must use the bulk api + # if there is only one US you must use patch (repo.save) + # the new US position is the position of the previous US + 1 + # if the previous US has a position value that it is equal to other USs, you must send all the USs with that position value only if they are before of the target position + # with this USs if it's a patch you must add them to the header, if is a bulk you must send them with the other USs moveUs: (ctx, usList, newUsIndex, newSprintId) -> oldSprintId = usList[0].milestone project = usList[0].project - movedFromClosedSprint = false - movedToClosedSprint = false + if oldSprintId + sprint = @scope.sprintsById[oldSprintId] || @scope.closedSprintsById[oldSprintId] - sprint = @scope.sprintsById[oldSprintId] + if newSprintId + newSprint = @scope.sprintsById[newSprintId] || @scope.closedSprintsById[newSprintId] - # Move from closed sprint - if !sprint && @scope.closedSprintsById - sprint = @scope.closedSprintsById[oldSprintId] - movedFromClosedSprint = true if sprint + currentSprintId = if newSprintId != oldSprintId then newSprintId else oldSprintId - newSprint = @scope.sprintsById[newSprintId] + orderList = null + orderField = "" - # Move to closed sprint - if !newSprint && newSprintId - newSprint = @scope.closedSprintsById[newSprintId] - movedToClosedSprint = true if newSprint + if newSprintId != oldSprintId + if newSprintId == null # From sprint to backlog + for us, key in usList # delete from sprint userstories + _.remove sprint.user_stories, (it) -> it.id == us.id - # In the same sprint or in the backlog - if newSprintId == oldSprintId - items = null - userstories = null + orderField = "backlog_order" + orderList = @.backlogOrder - if newSprintId == null - userstories = @scope.userstories - else - userstories = newSprint.user_stories + beforeDestination = _.slice(@scope.userstories, 0, newUsIndex) + afterDestination = _.slice(@scope.userstories, newUsIndex) - @scope.$apply -> - for us, key in usList - r = userstories.indexOf(us) - userstories.splice(r, 1) + @scope.userstories = @scope.userstories.concat(usList) + else # From backlog to sprint + for us in usList # delete from sprint userstories + _.remove @scope.userstories, (it) -> it.id == us.id - args = [newUsIndex, 0].concat(usList) - Array.prototype.splice.apply(userstories, args) + orderField = "sprint_order" + orderList = @.milestonesOrder[newSprint.id] - # If in backlog - if newSprintId == null - # Rehash userstories order field + beforeDestination = _.slice(newSprint.user_stories, 0, newUsIndex) + afterDestination = _.slice(newSprint.user_stories, newUsIndex) - items = @.resortUserStories(userstories, "backlog_order") - data = @.prepareBulkUpdateData(items, "backlog_order") - - # Persist in bulk all affected - # userstories with order change - @rs.userstories.bulkUpdateBacklogOrder(project, data).then => - @rootscope.$broadcast("sprint:us:moved") - - # For sprint - else - # Rehash userstories order field - items = @.resortUserStories(userstories, "sprint_order") - data = @.prepareBulkUpdateData(items, "sprint_order") - - # Persist in bulk all affected - # userstories with order change - @rs.userstories.bulkUpdateSprintOrder(project, data).then => - @rootscope.$broadcast("sprint:us:moved") - - return promise - - # From sprint to backlog - if newSprintId == null - us.milestone = null for us in usList - - @scope.$apply => - # Add new us to backlog userstories list - # @scope.userstories.splice(newUsIndex, 0, us) - args = [newUsIndex, 0].concat(usList) - Array.prototype.splice.apply(@scope.userstories, args) - - for us, key in usList - r = sprint.user_stories.indexOf(us) - sprint.user_stories.splice(r, 1) - - # Persist the milestone change of userstory - promise = @repo.save(us) - - # Rehash userstories order field - # and persist in bulk all changes. - promise = promise.then => - items = @.resortUserStories(@scope.userstories, "backlog_order") - data = @.prepareBulkUpdateData(items, "backlog_order") - return @rs.userstories.bulkUpdateBacklogOrder(us.project, data).then => - @rootscope.$broadcast("sprint:us:moved") - - if movedFromClosedSprint - @rootscope.$broadcast("backlog:load-closed-sprints") - - promise.then null, -> - console.log "FAIL" # TODO - - return promise - - # From backlog to sprint - if oldSprintId == null - us.milestone = newSprintId for us in usList - args = [newUsIndex, 0].concat(usList) - - # Add moving us to sprint user stories list - Array.prototype.splice.apply(newSprint.user_stories, args) - - # Remove moving us from backlog userstories lists. - for us, key in usList - r = @scope.userstories.indexOf(us) - @scope.userstories.splice(r, 1) - - # From sprint to sprint + newSprint.user_stories = newSprint.user_stories.concat(usList) else - us.milestone = newSprintId for us in usList + if oldSprintId == null # backlog + orderField = "backlog_order" + orderList = @.backlogOrder - @scope.$apply => - args = [newUsIndex, 0].concat(usList) + list = _.filter @scope.userstories, (listIt) -> # Remove moved US from list + return !_.find usList, (moveIt) -> return listIt.id == moveIt.id - # Add new us to backlog userstories list - Array.prototype.splice.apply(newSprint.user_stories, args) + beforeDestination = _.slice(list, 0, newUsIndex) + afterDestination = _.slice(list, newUsIndex) + else # sprint + orderField = "sprint_order" + orderList = @.milestonesOrder[sprint.id] - # Remove the us from the sprint list. - for us in usList - r = sprint.user_stories.indexOf(us) - sprint.user_stories.splice(r, 1) + list = _.filter newSprint.user_stories, (listIt) -> # Remove moved US from list + return !_.find usList, (moveIt) -> return listIt.id == moveIt.id - #Persist the milestone change of userstory - promises = _.map usList, (us) => @repo.save(us) + beforeDestination = _.slice(list, 0, newUsIndex) + afterDestination = _.slice(list, newUsIndex) - #Rehash userstories order field - #and persist in bulk all changes. - promise = @q.all(promises).then => - items = @.resortUserStories(newSprint.user_stories, "sprint_order") - data = @.prepareBulkUpdateData(items, "sprint_order") + # previous us + previous = beforeDestination[beforeDestination.length - 1] - @rs.userstories.bulkUpdateSprintOrder(project, data).then (result) => - @rootscope.$broadcast("sprint:us:moved") + # this will store the previous us with the same position + setPreviousOrders = [] - @rs.userstories.bulkUpdateBacklogOrder(project, data).then => - @rootscope.$broadcast("sprint:us:moved") + if !previous + startIndex = 0 + else if previous + startIndex = orderList[previous.id] + 1 - if movedToClosedSprint || movedFromClosedSprint - @scope.$broadcast("backlog:load-closed-sprints") + previousWithTheSameOrder = _.filter beforeDestination, (it) -> it[orderField] == orderList[previous.id] - promise.then null, -> - console.log "FAIL" # TODO + # we must send the USs previous to the dropped USs to tell the backend which USs are before the dropped USs, + # if they have the same value to order, the backend doens't know after which one do you want to drop the USs + if previousWithTheSameOrder.length > 1 + setPreviousOrders = _.map previousWithTheSameOrder, (it) -> {us_id: it.id, order: orderList[it.id]} + + modifiedUs = [] + + for us, key in usList # update sprint and new position + us.milestone = currentSprintId + us[orderField] = startIndex + key + orderList[us.id] = us[orderField] + + modifiedUs.push({us_id: us.id, order: us[orderField]}) + + startIndex = orderList[usList[usList.length - 1].id] + + for it, key in afterDestination # increase position of the us after the dragged us's + orderList[it.id] = startIndex + key + 1 + + # refresh order + @scope.userstories = _.sortBy @scope.userstories, (it) => @.backlogOrder[it.id] + + for sprint in @scope.sprints + sprint.user_stories = _.sortBy sprint.user_stories, (it) => @.milestonesOrder[sprint.id][it.id] + + for sprint in @scope.closedSprints + sprint.user_stories = _.sortBy sprint.user_stories, (it) => @.milestonesOrder[sprint.id][it.id] + + #saving + if usList.length > 1 && (newSprintId != oldSprintId) # drag multiple to sprint + data = modifiedUs.concat(setPreviousOrders) + promise = @rs.userstories.bulkUpdateMilestone(project, newSprintId, data) + else if usList.length > 1 # drag multiple in backlog + data = modifiedUs.concat(setPreviousOrders) + promise = @rs.userstories.bulkUpdateBacklogOrder(project, data) + else # drag single + setOrders = {} + for it in setPreviousOrders + setOrders[it.us_id] = it.order + + options = { + headers: { + "set-orders": JSON.stringify(setOrders) + } + } + + promise = @repo.save(usList[0], true, {}, options, true) + + promise.then () => + @rootscope.$broadcast("sprint:us:moved") + + if @scope.closedSprintsById && @scope.closedSprintsById[oldSprintId] + @rootscope.$broadcast("backlog:load-closed-sprints") return promise - isFilterSelected: (type, id) -> - if @searchdata[type]? and @searchdata[type][id] - return true - return false - - setSearchDataFilters: () -> - urlfilters = @.getUrlFilters() - - if urlfilters.q - @scope.filtersQ = @scope.filtersQ or urlfilters.q - - @searchdata = {} - for name, value of urlfilters - if not @searchdata[name]? - @searchdata[name] = {} - - for val in taiga.toString(value).split(",") - @searchdata[name][val] = true - - getUrlFilters: -> - return _.pick(@location.search(), "status", "tags", "q") - - generateFilters: -> - urlfilters = @.getUrlFilters() - @scope.filters = {} - - loadFilters = {} - loadFilters.project = @scope.projectId - loadFilters.tags = urlfilters.tags - loadFilters.status = urlfilters.status - loadFilters.q = urlfilters.q - loadFilters.milestone = 'null' - - return @rs.userstories.filtersData(loadFilters).then (data) => - choicesFiltersFormat = (choices, type, byIdObject) => - _.map choices, (t) -> - t.type = type - return t - - tagsFilterFormat = (tags) => - return _.map tags, (t) -> - t.id = t.name - t.type = 'tags' - return t - - # Build filters data structure - @scope.filters.status = choicesFiltersFormat(data.statuses, "status", @scope.usStatusById) - @scope.filters.tags = tagsFilterFormat(data.tags) - - selectedTags = _.filter(@scope.filters.tags, "selected") - selectedTags = _.map(selectedTags, "id") - - selectedStatuses = _.filter(@scope.filters.status, "selected") - selectedStatuses = _.map(selectedStatuses, "id") - - @.markSelectedFilters(@scope.filters, urlfilters) - - #store query params - @rs.userstories.storeQueryParams(@scope.projectId, { - "status": selectedStatuses, - "tags": selectedTags, - "project": @scope.projectId - "milestone": null - }) - - 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 - ## Template actions updateUserStoryStatus: () -> - @.setSearchDataFilters() @.generateFilters().then () => @rootscope.$broadcast("filters:update") @.loadProjectStats() @@ -807,8 +692,15 @@ BacklogDirective = ($repo, $rootscope, $translate) -> text = $translate.instant("BACKLOG.TAGS.SHOW") elm.text(text) + openFilterInit = ($scope, $el, $ctrl) -> + sidebar = $el.find("sidebar.backlog-filter") + + sidebar.addClass("active") + + $ctrl.activeFilters = true + showHideFilter = ($scope, $el, $ctrl) -> - sidebar = $el.find("sidebar.filters-bar") + sidebar = $el.find("sidebar.backlog-filter") sidebar.one "transitionend", () -> timeout 150, -> $rootscope.$broadcast("resize") @@ -824,11 +716,6 @@ BacklogDirective = ($repo, $rootscope, $translate) -> toggleText(target, [hideText, showText]) - if !sidebar.hasClass("active") - $ctrl.resetFilters() - else - $ctrl.restoreFilters() - $ctrl.toggleActiveFilters() ## Filters Link @@ -847,11 +734,13 @@ BacklogDirective = ($repo, $rootscope, $translate) -> linkFilters($scope, $el, $attrs, $ctrl) linkDoomLine($scope, $el, $attrs, $ctrl) - filters = $ctrl.getUrlFilters() + filters = $ctrl.location.search() if filters.status || filters.tags || - filters.q - showHideFilter($scope, $el, $ctrl) + filters.q || + filters.assigned_to || + filters.owner + openFilterInit($scope, $el, $ctrl) $scope.$on "showTags", () -> showHideTags($ctrl) diff --git a/app/coffee/modules/backlog/sortable.coffee b/app/coffee/modules/backlog/sortable.coffee index eeb2948a..0575c96b 100644 --- a/app/coffee/modules/backlog/sortable.coffee +++ b/app/coffee/modules/backlog/sortable.coffee @@ -42,7 +42,7 @@ deleteElement = (el) -> $(el).off() $(el).remove() -BacklogSortableDirective = ($repo, $rs, $rootscope, $tgConfirm, $translate) -> +BacklogSortableDirective = ($repo, $rs, $rootscope, $tgConfirm) -> link = ($scope, $el, $attrs) -> bindOnce $scope, "project", (project) -> # If the user has not enough permissions we don't enable the sortable @@ -51,10 +51,6 @@ BacklogSortableDirective = ($repo, $rs, $rootscope, $tgConfirm, $translate) -> initIsBacklog = false - filterError = -> - text = $translate.instant("BACKLOG.SORTABLE_FILTER_ERROR") - $tgConfirm.notify("error", text) - drake = dragula([$el[0], $('.empty-backlog')[0]], { copySortSource: false, copy: false, @@ -63,18 +59,11 @@ BacklogSortableDirective = ($repo, $rs, $rootscope, $tgConfirm, $translate) -> if !$(item).hasClass('row') return false - # it doesn't move is the filter is open - parent = $(item).parent() - initIsBacklog = parent.hasClass('backlog-table-body') - - if initIsBacklog && $el.hasClass("active-filters") - filterError() - return false - return true }) drake.on 'drag', (item, container) -> + # it doesn't move is the filter is open parent = $(item).parent() initIsBacklog = parent.hasClass('backlog-table-body') @@ -88,6 +77,8 @@ BacklogSortableDirective = ($repo, $rs, $rootscope, $tgConfirm, $translate) -> $(item).addClass('backlog-us-mirror') drake.on 'dragend', (item) -> + parent = $(item).parent() + $('.doom-line').remove() parent = $(item).parent() @@ -102,8 +93,6 @@ BacklogSortableDirective = ($repo, $rs, $rootscope, $tgConfirm, $translate) -> $(document.body).removeClass("drag-active") - items = $(item).parent().find('.row') - sprint = null firstElement = if dragMultipleItems.length then dragMultipleItems[0] else item @@ -131,11 +120,7 @@ BacklogSortableDirective = ($repo, $rs, $rootscope, $tgConfirm, $translate) -> usList = _.map dragMultipleItems, (item) -> return item = $(item).scope().us else - usList = _.map items, (item) -> - item = $(item) - itemUs = item.scope().us - - return itemUs + usList = [$(item).scope().us] $scope.$emit("sprint:us:move", usList, index, sprint) @@ -158,6 +143,5 @@ module.directive("tgBacklogSortable", [ "$tgResources", "$rootScope", "$tgConfirm", - "$translate", BacklogSortableDirective ]) diff --git a/app/coffee/modules/base/repository.coffee b/app/coffee/modules/base/repository.coffee index 268a86a6..290bed50 100644 --- a/app/coffee/modules/base/repository.coffee +++ b/app/coffee/modules/base/repository.coffee @@ -41,7 +41,7 @@ class RepositoryService extends taiga.Service defered = @q.defer() url = @urls.resolve(name) - promise = @http.post(url, JSON.stringify(data)) + promise = @http.post(url, JSON.stringify(data), extraParams) promise.success (_data, _status) => defered.resolve(@model.make_model(name, _data, null, dataTypes)) @@ -67,7 +67,7 @@ class RepositoryService extends taiga.Service promises = _.map(models, (x) => @.save(x, true)) return @q.all(promises) - save: (model, patch=true) -> + save: (model, patch=true, params = {}, options, returnHeaders = false) -> defered = @q.defer() if not model.isModified() and patch @@ -75,20 +75,25 @@ class RepositoryService extends taiga.Service return defered.promise url = @.resolveUrlForModel(model) + data = JSON.stringify(model.getAttrs(patch)) if patch - promise = @http.patch(url, data) + promise = @http.patch(url, data, params, options) else - promise = @http.put(url, data) + promise = @http.put(url, data, params, options) - promise.success (data, status) => + promise.success (data, status, headers, response) => model._isModified = false model._attrs = _.extend(model.getAttrs(), data) model._modifiedAttrs = {} model.applyCasts() - defered.resolve(model) + + if returnHeaders + defered.resolve([model, headers()]) + else + defered.resolve(model) promise.error (data, status) -> defered.reject(data) diff --git a/app/coffee/modules/common/lightboxes.coffee b/app/coffee/modules/common/lightboxes.coffee index 0c8a8b4a..9f4c28c3 100644 --- a/app/coffee/modules/common/lightboxes.coffee +++ b/app/coffee/modules/common/lightboxes.coffee @@ -378,22 +378,28 @@ CreateEditUserstoryDirective = ($repo, $model, $rs, $rootScope, lightboxService, .target(submitButton) .start() + params = { + include_attachments: true, + include_tasks: true + } + if $scope.isNew promise = $repo.create("userstories", $scope.us) broadcastEvent = "usform:new:success" else - promise = $repo.save($scope.us) + promise = $repo.save($scope.us, true) broadcastEvent = "usform:edit:success" promise.then (data) -> - deleteAttachments(data).then () => createAttachments(data) + deleteAttachments(data) + .then () => createAttachments(data) + .then () => + currentLoading.finish() + lightboxService.close($el) - return data + $rs.userstories.getByRef(data.project, data.ref, params).then (us) -> + $rootScope.$broadcast(broadcastEvent, us) - promise.then (data) -> - currentLoading.finish() - lightboxService.close($el) - $rootScope.$broadcast(broadcastEvent, data) promise.then null, (data) -> currentLoading.finish() @@ -433,7 +439,7 @@ module.directive("tgLbCreateEditUserstory", [ "$translate", "$tgConfirm", "$q", - "tgAttachmentsService", + "tgAttachmentsService" CreateEditUserstoryDirective ]) @@ -442,7 +448,7 @@ module.directive("tgLbCreateEditUserstory", [ ## Creare Bulk Userstories Lightbox Directive ############################################################################# -CreateBulkUserstoriesDirective = ($repo, $rs, $rootscope, lightboxService, $loading) -> +CreateBulkUserstoriesDirective = ($repo, $rs, $rootscope, lightboxService, $loading, $model) -> link = ($scope, $el, attrs) -> form = null @@ -469,6 +475,7 @@ CreateBulkUserstoriesDirective = ($repo, $rs, $rootscope, lightboxService, $load promise = $rs.userstories.bulkCreate($scope.new.projectId, $scope.new.statusId, $scope.new.bulk) promise.then (result) -> + result = _.map(result.data, (x) => $model.make_model('userstories', x)) currentLoading.finish() $rootscope.$broadcast("usform:bulk:success", result) lightboxService.close($el) @@ -494,6 +501,7 @@ module.directive("tgLbCreateBulkUserstories", [ "$rootScope", "lightboxService", "$tgLoading", + "$tgModel", CreateBulkUserstoriesDirective ]) @@ -535,7 +543,7 @@ AssignedToLightboxDirective = (lightboxService, lightboxKeyboardNavigationServic visibleUsers = _.map visibleUsers, (user) -> user.avatar = avatarService.getAvatar(user) - selected.avatar = avatarService.getAvatar(selected) + selected.avatar = avatarService.getAvatar(selected) if selected ctx = { selected: selected diff --git a/app/coffee/modules/controllerMixins.coffee b/app/coffee/modules/controllerMixins.coffee index 18724b4b..68454e25 100644 --- a/app/coffee/modules/controllerMixins.coffee +++ b/app/coffee/modules/controllerMixins.coffee @@ -110,4 +110,179 @@ class FiltersMixin location = if load then @location else @location.noreload(@scope) location.search(name, value) + applyStoredFilters: (projectSlug, key) -> + if _.isEmpty(@location.search()) + filters = @.getFilters(projectSlug, key) + if Object.keys(filters).length + @location.search(filters) + @location.replace() + + return true + + return false + + storeFilters: (projectSlug, params, filtersHashSuffix) -> + ns = "#{projectSlug}:#{filtersHashSuffix}" + hash = taiga.generateHash([projectSlug, ns]) + @storage.set(hash, params) + + getFilters: (projectSlug, filtersHashSuffix) -> + ns = "#{projectSlug}:#{filtersHashSuffix}" + hash = taiga.generateHash([projectSlug, ns]) + + return @storage.get(hash) or {} + + formatSelectedFilters: (type, list, urlIds) -> + selectedIds = urlIds.split(',') + selectedFilters = _.filter list, (it) -> + selectedIds.indexOf(_.toString(it.id)) != -1 + + return _.map selectedFilters, (it) -> + return { + id: it.id + key: type + ":" + it.id + dataType: type, + name: it.name + color: it.color + } + taiga.FiltersMixin = FiltersMixin + +############################################################################# +## Us Filters Mixin +############################################################################# + +class UsFiltersMixin + changeQ: (q) -> + @.replaceFilter("q", q) + @.filtersReloadContent() + @.generateFilters() + + removeFilter: (filter) -> + @.unselectFilter(filter.dataType, filter.id) + @.filtersReloadContent() + @.generateFilters() + + addFilter: (newFilter) -> + @.selectFilter(newFilter.category.dataType, newFilter.filter.id) + @.filtersReloadContent() + @.generateFilters() + + selectCustomFilter: (customFilter) -> + @.replaceAllFilters(customFilter.filter) + @.filtersReloadContent() + @.generateFilters() + + saveCustomFilter: (name) -> + filters = {} + urlfilters = @location.search() + filters.tags = urlfilters.tags + filters.status = urlfilters.status + filters.assigned_to = urlfilters.assigned_to + filters.owner = urlfilters.owner + + @filterRemoteStorageService.getFilters(@scope.projectId, @.storeCustomFiltersName).then (userFilters) => + userFilters[name] = filters + + @filterRemoteStorageService.storeFilters(@scope.projectId, userFilters, @.storeCustomFiltersName).then(@.generateFilters) + + removeCustomFilter: (customFilter) -> + @filterRemoteStorageService.getFilters(@scope.projectId, @.storeCustomFiltersName).then (userFilters) => + delete userFilters[customFilter.id] + + @filterRemoteStorageService.storeFilters(@scope.projectId, userFilters, @.storeCustomFiltersName).then(@.generateFilters) + @.generateFilters() + + generateFilters: -> + @.storeFilters(@params.pslug, @location.search(), @.storeFiltersName) + + urlfilters = @location.search() + + loadFilters = {} + loadFilters.project = @scope.projectId + loadFilters.tags = urlfilters.tags + loadFilters.status = urlfilters.status + loadFilters.assigned_to = urlfilters.assigned_to + loadFilters.owner = urlfilters.owner + loadFilters.q = urlfilters.q + + return @q.all([ + @rs.userstories.filtersData(loadFilters), + @filterRemoteStorageService.getFilters(@scope.projectId, @.storeCustomFiltersName) + ]).then (result) => + data = result[0] + customFiltersRaw = result[1] + + statuses = _.map data.statuses, (it) -> + it.id = it.id.toString() + + return it + tags = _.map data.tags, (it) -> + it.id = it.name + + return it + assignedTo = _.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 + owner = _.map data.owners, (it) -> + it.id = it.id.toString() + it.name = it.full_name + + return it + + @.selectedFilters = [] + + if loadFilters.status + selected = @.formatSelectedFilters("status", statuses, loadFilters.status) + @.selectedFilters = @.selectedFilters.concat(selected) + + if loadFilters.tags + selected = @.formatSelectedFilters("tags", tags, loadFilters.tags) + @.selectedFilters = @.selectedFilters.concat(selected) + + if loadFilters.assigned_to + selected = @.formatSelectedFilters("assigned_to", assignedTo, loadFilters.assigned_to) + @.selectedFilters = @.selectedFilters.concat(selected) + + if loadFilters.owner + selected = @.formatSelectedFilters("owner", owner, loadFilters.owner) + @.selectedFilters = @.selectedFilters.concat(selected) + + @.filterQ = loadFilters.q + + @.filters = [ + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.STATUS"), + dataType: "status", + content: statuses + }, + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.TAGS"), + dataType: "tags", + content: tags, + hideEmpty: true + }, + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.ASSIGNED_TO"), + dataType: "assigned_to", + content: assignedTo + }, + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.CREATED_BY"), + dataType: "owner", + content: owner + } + ]; + + @.customFilters = [] + _.forOwn customFiltersRaw, (value, key) => + @.customFilters.push({id: key, name: key, filter: value}) + + +taiga.UsFiltersMixin = UsFiltersMixin diff --git a/app/coffee/modules/issues/list.coffee b/app/coffee/modules/issues/list.coffee index a8cfa0b9..8c5c3c18 100644 --- a/app/coffee/modules/issues/list.coffee +++ b/app/coffee/modules/issues/list.coffee @@ -32,6 +32,7 @@ groupBy = @.taiga.groupBy bindOnce = @.taiga.bindOnce debounceLeading = @.taiga.debounceLeading startswith = @.taiga.startswith +bindMethods = @.taiga.bindMethods module = angular.module("taigaIssues") @@ -55,21 +56,23 @@ class IssuesController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi "$tgEvents", "$tgAnalytics", "$translate", - "tgErrorHandlingService" + "tgErrorHandlingService", + "$tgStorage", + "tgFilterRemoteStorageService" ] + filtersHashSuffix: "issues-filters" + myFiltersHashSuffix: "issues-my-filters" + constructor: (@scope, @rootscope, @repo, @confirm, @rs, @urls, @params, @q, @location, @appMetaService, - @navUrls, @events, @analytics, @translate, @errorHandlingService) -> + @navUrls, @events, @analytics, @translate, @errorHandlingService, @storage, @filterRemoteStorageService) -> + bindMethods(@) + @scope.sectionName = "Issues" @scope.filters = {} @.voting = false - if _.isEmpty(@location.search()) - filters = @rs.issues.getFilters(@params.pslug) - filters.page = 1 - @location.search(filters) - @location.replace() - return + return if @.applyStoredFilters(@params.pslug, @.filtersHashSuffix) promise = @.loadInitialData() @@ -89,13 +92,196 @@ class IssuesController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi @analytics.trackEvent("issue", "create", "create issue on issues list", 1) @.loadIssues() + changeQ: (q) -> + @.unselectFilter("page") + @.replaceFilter("q", q) + @.loadIssues() + @.generateFilters() + + removeFilter: (filter) -> + @.unselectFilter("page") + @.unselectFilter(filter.dataType, filter.id) + @.loadIssues() + @.generateFilters() + + addFilter: (newFilter) -> + @.unselectFilter("page") + @.selectFilter(newFilter.category.dataType, newFilter.filter.id) + @.loadIssues() + @.generateFilters() + + selectCustomFilter: (customFilter) -> + orderBy = @location.search().order_by + + if orderBy + customFilter.filter.order_by = orderBy + + @.unselectFilter("page") + @.replaceAllFilters(customFilter.filter) + @.loadIssues() + @.generateFilters() + + removeCustomFilter: (customFilter) -> + console.log "oooo" + @filterRemoteStorageService.getFilters(@scope.projectId, @.myFiltersHashSuffix).then (userFilters) => + console.log userFilters[customFilter.id] + delete userFilters[customFilter.id] + + @filterRemoteStorageService.storeFilters(@scope.projectId, userFilters, @.myFiltersHashSuffix).then(@.generateFilters) + + saveCustomFilter: (name) -> + filters = {} + urlfilters = @location.search() + filters.tags = urlfilters.tags + filters.status = urlfilters.status + filters.type = urlfilters.type + filters.severity = urlfilters.severity + filters.priority = urlfilters.priority + filters.assigned_to = urlfilters.assigned_to + filters.owner = urlfilters.owner + + @filterRemoteStorageService.getFilters(@scope.projectId, @.myFiltersHashSuffix).then (userFilters) => + userFilters[name] = filters + + @filterRemoteStorageService.storeFilters(@scope.projectId, userFilters, @.myFiltersHashSuffix).then(@.generateFilters) + + generateFilters: -> + @.storeFilters(@params.pslug, @location.search(), @.filtersHashSuffix) + + urlfilters = @location.search() + + loadFilters = {} + loadFilters.project = @scope.projectId + loadFilters.tags = urlfilters.tags + loadFilters.status = urlfilters.status + loadFilters.type = urlfilters.type + loadFilters.severity = urlfilters.severity + loadFilters.priority = urlfilters.priority + loadFilters.assigned_to = urlfilters.assigned_to + loadFilters.owner = urlfilters.owner + loadFilters.q = urlfilters.q + + return @q.all([ + @rs.issues.filtersData(loadFilters), + @filterRemoteStorageService.getFilters(@scope.projectId, @.myFiltersHashSuffix) + ]).then (result) => + data = result[0] + customFiltersRaw = result[1] + + statuses = _.map data.statuses, (it) -> + it.id = it.id.toString() + + return it + type = _.map data.types, (it) -> + it.id = it.id.toString() + + return it + severity = _.map data.severities, (it) -> + it.id = it.id.toString() + + return it + priority = _.map data.priorities, (it) -> + it.id = it.id.toString() + + return it + tags = _.map data.tags, (it) -> + it.id = it.name + + return it + assignedTo = _.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 + owner = _.map data.owners, (it) -> + it.id = it.id.toString() + it.name = it.full_name + + return it + + @.selectedFilters = [] + + if loadFilters.status + selected = @.formatSelectedFilters("status", statuses, loadFilters.status) + @.selectedFilters = @.selectedFilters.concat(selected) + + if loadFilters.tags + selected = @.formatSelectedFilters("tags", tags, loadFilters.tags) + @.selectedFilters = @.selectedFilters.concat(selected) + + if loadFilters.assigned_to + selected = @.formatSelectedFilters("assigned_to", assignedTo, loadFilters.assigned_to) + @.selectedFilters = @.selectedFilters.concat(selected) + + if loadFilters.owner + selected = @.formatSelectedFilters("owner", owner, loadFilters.owner) + @.selectedFilters = @.selectedFilters.concat(selected) + + if loadFilters.type + selected = @.formatSelectedFilters("type", type, loadFilters.type) + @.selectedFilters = @.selectedFilters.concat(selected) + + if loadFilters.severity + selected = @.formatSelectedFilters("severity", severity, loadFilters.severity) + @.selectedFilters = @.selectedFilters.concat(selected) + + if loadFilters.priority + selected = @.formatSelectedFilters("priority", priority, loadFilters.priority) + @.selectedFilters = @.selectedFilters.concat(selected) + + @.filterQ = loadFilters.q + + @.filters = [ + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.TYPE"), + dataType: "type", + content: type + }, + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.SEVERITY"), + dataType: "severity", + content: severity + }, + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.PRIORITIES"), + dataType: "priority", + content: priority + }, + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.STATUS"), + dataType: "status", + content: statuses + }, + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.TAGS"), + dataType: "tags", + content: tags + }, + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.ASSIGNED_TO"), + dataType: "assigned_to", + content: assignedTo + }, + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.CREATED_BY"), + dataType: "owner", + content: owner + } + ]; + + @.customFilters = [] + _.forOwn customFiltersRaw, (value, key) => + @.customFilters.push({id: key, name: key, filter: value}) + 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.getBySlug(@params.pslug).then (project) => @@ -117,160 +303,15 @@ class IssuesController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi return project - getUrlFilters: -> - filters = _.pick(@location.search(), "page", "tags", "status", "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 - - loadFilters = {} - loadFilters.project = @scope.projectId - loadFilters.tags = urlfilters.tags - loadFilters.status = urlfilters.status - loadFilters.q = urlfilters.q - loadFilters.types = urlfilters.types - loadFilters.severities = urlfilters.severities - loadFilters.priorities = urlfilters.priorities - loadFilters.assigned_to = urlfilters.assignedTo - loadFilters.owner = urlfilters.createdBy - - # Load default filters data - promise = promise.then => - return @rs.issues.filtersData(loadFilters) - - # Format filters and set them on scope - return promise.then (data) => - usersFiltersFormat = (users, type, unknownOption) => - reformatedUsers = _.map users, (t) => - t.type = type - t.name = if t.full_name then t.full_name else unknownOption - - return t - - 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) -> - t.type = type - return t - - tagsFilterFormat = (tags) => - return _.map tags, (t) -> - t.id = t.name - t.type = 'tags' - return t - - # Build filters data structure - @scope.filters.status = choicesFiltersFormat(data.statuses, "status", @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.owners, "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) - # We need to guarantee that the last petition done here is the finally used # When searching by text loadIssues can be called fastly with different parameters and # can be resolved in a different order than generated # We count the requests made and only if the callback is for the last one data is updated loadIssuesRequests: 0 loadIssues: => - @scope.urlFilters = @.getUrlFilters() + params = @location.search() - # 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 == "status" - name = "status" - else if name == "types" - name = "type" - @scope.httpParams[name] = values - - promise = @rs.issues.list(@scope.projectId, @scope.httpParams) + promise = @rs.issues.list(@scope.projectId, params) @.loadIssuesRequests += 1 promise.index = @.loadIssuesRequests promise.then (data) => @@ -289,26 +330,10 @@ class IssuesController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi return promise.then (project) => @.fillUsersAndRoles(project.members, project.roles) @.initializeSubscription() - @.loadFilters() + @.generateFilters() return @.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) @@ -338,6 +363,12 @@ class IssuesController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi return @rs.issues.downvote(issueId).then(onSuccess, onError) + getOrderBy: -> + if _.isString(@location.search().order_by) + return @location.search().order_by + else + return "created_date" + module.controller("IssuesController", IssuesController) ############################################################################# @@ -431,28 +462,40 @@ IssuesDirective = ($log, $location, $template, $compile) -> ## Issues Filters linkOrdering = ($scope, $el, $attrs, $ctrl) -> # Draw the arrow the first time - currentOrder = $ctrl.getUrlFilter("orderBy") or "created_date" + + currentOrder = $ctrl.getOrderBy() + if currentOrder - icon = if startswith(currentOrder, "-") then "icon-arrow-up" else "icon-arrow-bottom" + icon = if startswith(currentOrder, "-") then "icon-arrow-up" else "icon-arrow-down" colHeadElement = $el.find(".row.title > div[data-fieldname='#{trim(currentOrder, "-")}']") - colHeadElement.html("#{colHeadElement.html()}") + + svg = $("").attr("svg-icon", icon) + + colHeadElement.append(svg) + $compile(colHeadElement.contents())($scope); $el.on "click", ".row.title > div", (event) -> target = angular.element(event.currentTarget) - currentOrder = $ctrl.getUrlFilter("orderBy") + currentOrder = $ctrl.getOrderBy() newOrder = target.data("fieldname") finalOrder = if currentOrder == newOrder then "-#{newOrder}" else newOrder $scope.$apply -> - $ctrl.replaceFilter("orderBy", finalOrder) - $ctrl.storeFilters() + $ctrl.replaceFilter("order_by", finalOrder) + + $ctrl.storeFilters($ctrl.params.pslug, $location.search(), $ctrl.filtersHashSuffix) $ctrl.loadIssues().then -> # Update the arrow - $el.find(".row.title > div > span.icon").remove() - icon = if startswith(finalOrder, "-") then "icon-arrow-up" else "icon-arrow-bottom" - target.html("#{target.html()}") + $el.find(".row.title > div > tg-svg").remove() + icon = if startswith(finalOrder, "-") then "icon-arrow-up" else "icon-arrow-down" + + svg = $("") + .attr("svg-icon", icon) + + target.append(svg) + $compile(target.contents())($scope); ## Issues Link link = ($scope, $el, $attrs) -> @@ -468,253 +511,6 @@ IssuesDirective = ($log, $location, $template, $compile) -> module.directive("tgIssues", ["$log", "$tgLocation", "$tgTemplate", "$compile", IssuesDirective]) -############################################################################# -## Issues Filters Directive -############################################################################# - -IssuesFiltersDirective = ($q, $log, $location, $rs, $confirm, $loading, $template, $translate, $compile, $auth) -> - template = $template.get("issue/issues-filters.html", true) - templateSelected = $template.get("issue/issues-filters-selected.html", true) - - link = ($scope, $el, $attrs) -> - $ctrl = $el.closest(".wrapper").controller() - - selectedFilters = [] - - showFilters = (title, type) -> - $el.find(".filters-cats").hide() - $el.find(".filter-list").removeClass("hidden") - $el.find(".breadcrumb").removeClass("hidden") - $el.find("h2 .subfilter .title").html(title) - $el.find("h2 .subfilter .title").prop("data-type", type) - - showCategories = -> - $el.find(".filters-cats").show() - $el.find(".filter-list").addClass("hidden") - $el.find(".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) -> - _.filter selectedFilters, (f) => - if f.color - f.style = "border-left: 3px solid #{f.color}" - - html = templateSelected({filters:selectedFilters}) - html = $compile(html)($scope) - $el.find(".filters-applied").html(html) - - if $auth.isAuthenticated() && selectedFilters.length > 0 - $el.find(".save-filters").show() - else - $el.find(".save-filters").hide() - - renderFilters = (filters) -> - _.filter filters, (f) => - if f.color - f.style = "border-left: 3px solid #{f.color}" - - html = template({filters:filters}) - html = $compile(html)($scope) - $el.find(".filter-list").html(html) - - getFiltersType = () -> - return $el.find(".subfilter .title").prop('data-type') - - reloadIssues = () -> - currentFiltersType = getFiltersType() - - $q.all([$ctrl.loadIssues(), $ctrl.loadFilters()]).then () -> - filters = $scope.filters[currentFiltersType] - renderFilters(_.reject(filters, "selected")) - - 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] - filterId = if type == 'tags' then taiga.toString(id) else id - filter = _.find(filters, {id: filterId}) - 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) - $ctrl.selectFilter(type, id) - $ctrl.selectFilter("page", 1) - $ctrl.storeFilters() - else - selectedFilters = _.reject selectedFilters, (f) -> - return f.id == filter.id && f.type == filter.type - - $ctrl.unselectFilter(type, id) - $ctrl.selectFilter("page", 1) - $ctrl.storeFilters() - - reloadIssues() - - renderSelectedFilters(selectedFilters) - - currentFiltersType = getFiltersType() - - if type == currentFiltersType - renderFilters(_.reject(filters, "selected")) - - # Angular Watchers - $scope.$on "filters:loaded", (ctx, filters) -> - initializeSelectedFilters(filters) - - $scope.$on "filters:issueupdate", (ctx, filters) -> - html = template({filters:filters.status}) - html = $compile(html)($scope) - $el.find(".filter-list").html(html) - - selectQFilter = debounceLeading 100, (value, oldValue) -> - return if value is undefined or value == oldValue - - $ctrl.replaceFilter("page", null, true) - - if value.length == 0 - $ctrl.replaceFilter("q", null) - $ctrl.storeFilters() - else - $ctrl.replaceFilter("q", value) - $ctrl.storeFilters() - - reloadIssues() - - unwatchIssues = $scope.$watch "issues", (newValue) -> - if !_.isUndefined(newValue) - $scope.$watch("filtersQ", selectQFilter) - unwatchIssues() - - # Dom Event Handlers - $el.on "click", ".filters-cat-single", (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", ".back", (event) -> - event.preventDefault() - showCategories($el) - - $el.on "click", ".filters-applied .remove-filter", (event) -> - event.preventDefault() - target = angular.element(event.currentTarget).parent() - - 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 .remove-filter", (event) -> - event.preventDefault() - event.stopPropagation() - - target = angular.element(event.currentTarget) - customFilterName = target.parent().data('id') - title = $translate.instant("ISSUES.FILTERS.CONFIRM_DELETE.TITLE") - message = $translate.instant("ISSUES.FILTERS.CONFIRM_DELETE.MESSAGE", {customFilterName: customFilterName}) - - $confirm.askOnDelete(title, message).then (askResponse) -> - promise = $ctrl.deleteMyFilter(customFilterName) - promise.then -> - promise = $ctrl.loadMyFilters() - promise.then (filters) -> - askResponse.finish() - $scope.filters.myFilters = filters - renderFilters($scope.filters.myFilters) - promise.then null, -> - askResponse.finish() - promise.then null, -> - askResponse.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').removeClass("hidden") - $el.find('.my-filter-name').focus() - $scope.$apply() - - $el.on "keyup", ".my-filter-name", (event) -> - event.preventDefault() - if event.keyCode == 13 - target = angular.element(event.currentTarget) - newFilter = target.val() - currentLoading = $loading() - .target($el.find(".new")) - .start() - promise = $ctrl.saveCurrentFiltersTo(newFilter) - promise.then -> - loadPromise = $ctrl.loadMyFilters() - loadPromise.then (filters) -> - currentLoading.finish() - $scope.filters.myFilters = filters - - currentfilterstype = $el.find("h2 .subfilter .title").prop('data-type') - if currentfilterstype == "myFilters" - renderFilters($scope.filters.myFilters) - - $el.find('.my-filter-name').addClass("hidden") - $el.find('.save-filters').show() - - loadPromise.then null, -> - currentLoading.finish() - $confirm.notify("error", "Error loading custom filters") - - promise.then null, -> - currentLoading.finish() - $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').addClass("hidden") - $el.find('.save-filters').show() - - return {link:link} - -module.directive("tgIssuesFilters", ["$q", "$log", "$tgLocation", "$tgResources", "$tgConfirm", "$tgLoading", - "$tgTemplate", "$translate", "$compile", "$tgAuth", IssuesFiltersDirective]) - - ############################################################################# ## Issue status Directive (popover for change status) ############################################################################# diff --git a/app/coffee/modules/kanban/kanban-usertories.coffee b/app/coffee/modules/kanban/kanban-usertories.coffee new file mode 100644 index 00000000..0e811b13 --- /dev/null +++ b/app/coffee/modules/kanban/kanban-usertories.coffee @@ -0,0 +1,189 @@ +### +# Copyright (C) 2014-2016 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: kanban-userstories.service.coffee +### + +groupBy = @.taiga.groupBy + +class KanbanUserstoriesService extends taiga.Service + @.$inject = [] + + constructor: () -> + @.reset() + + reset: () -> + @.userstoriesRaw = [] + @.archivedStatus = [] + @.statusHide = [] + @.foldStatusChanged = {} + @.usByStatus = Immutable.Map() + + init: (project, usersById) -> + @.project = project + @.usersById = usersById + + resetFolds: () -> + @.foldStatusChanged = {} + @.refresh() + + toggleFold: (usId) -> + @.foldStatusChanged[usId] = !@.foldStatusChanged[usId] + @.refresh() + + set: (userstories) -> + @.userstoriesRaw = userstories + @.refreshRawOrder() + @.refresh() + + add: (us) -> + @.userstoriesRaw = @.userstoriesRaw.concat(us) + @.refreshRawOrder() + @.refresh() + + addArchivedStatus: (statusId) -> + @.archivedStatus.push(statusId) + + isUsInArchivedHiddenStatus: (usId) -> + us = @.getUsModel(usId) + + return @.archivedStatus.indexOf(us.status) != -1 && + @.statusHide.indexOf(us.status) != -1 + + hideStatus: (statusId) -> + @.deleteStatus(statusId) + @.statusHide.push(statusId) + + showStatus: (statusId) -> + _.remove @.statusHide, (it) -> return it == statusId + + getStatus: (statusId) -> + return _.filter @.userstoriesRaw, (us) -> return us.status == statusId + + deleteStatus: (statusId) -> + toDelete = _.filter @.userstoriesRaw, (us) -> return us.status == statusId + toDelete = _.map (it) -> return it.id + + @.archived = _.difference(@.archived, toDelete) + + @.userstoriesRaw = _.filter @.userstoriesRaw, (us) -> return us.status != statusId + + @.refresh() + + refreshRawOrder: () -> + @.order = {} + + @.order[it.id] = it.kanban_order for it in @.userstoriesRaw + + assignOrders: (order) -> + order = _.invert(order) + @.order = _.assign(@.order, order) + + @.refresh() + + move: (id, statusId, index) -> + us = @.getUsModel(id) + + usByStatus = _.filter @.userstoriesRaw, (it) => + return it.status == statusId + + usByStatus = _.sortBy usByStatus, (it) => @.order[it.id] + + usByStatusWithoutMoved = _.filter usByStatus, (it) => it.id != id + beforeDestination = _.slice(usByStatusWithoutMoved, 0, index) + afterDestination = _.slice(usByStatusWithoutMoved, index) + + setOrders = {} + + previous = beforeDestination[beforeDestination.length - 1] + + previousWithTheSameOrder = _.filter beforeDestination, (it) => + @.order[it.id] == @.order[previous.id] + + if previousWithTheSameOrder.length > 1 + for it in previousWithTheSameOrder + setOrders[it.id] = @.order[it.id] + + if !previous + @.order[us.id] = 0 + else if previous + @.order[us.id] = @.order[previous.id] + 1 + + for it, key in afterDestination + @.order[it.id] = @.order[us.id] + key + 1 + + us.status = statusId + us.kanban_order = @.order[us.id] + + @.refresh() + + return {"us_id": us.id, "order": @.order[us.id], "set_orders": setOrders} + + replace: (us) -> + @.usByStatus = @.usByStatus.map (status) -> + findedIndex = status.findIndex (usItem) -> + return usItem.get('id') == us.get('id') + + if findedIndex != -1 + status = status.set(findedIndex, us) + + return status + + replaceModel: (us) -> + @.userstoriesRaw = _.map @.userstoriesRaw, (usItem) -> + if us.id == usItem.id + return us + else + return usItem + + @.refresh() + + getUs: (id) -> + findedUs = null + + @.usByStatus.forEach (status) -> + findedUs = status.find (us) -> return us.get('id') == id + + return false if findedUs + + return findedUs + + getUsModel: (id) -> + return _.find @.userstoriesRaw, (us) -> return us.id == id + + refresh: -> + @.userstoriesRaw = _.sortBy @.userstoriesRaw, (it) => @.order[it.id] + + userstories = @.userstoriesRaw + userstories = _.map userstories, (usModel) => + us = {} + us.foldStatusChanged = @.foldStatusChanged[usModel.id] + us.model = usModel.getAttrs() + us.images = _.filter usModel.attachments, (it) -> return !!it.thumbnail_card_url + us.id = usModel.id + us.assigned_to = @.usersById[usModel.assigned_to] + us.colorized_tags = _.map us.model.tags, (tag) => + color = @.project.tags_colors[tag] + return {name: tag, color: color} + + return us + + usByStatus = _.groupBy userstories, (us) -> + return us.model.status + + @.usByStatus = Immutable.fromJS(usByStatus) + +angular.module("taigaKanban").service("tgKanbanUserstories", KanbanUserstoriesService) diff --git a/app/coffee/modules/kanban/main.coffee b/app/coffee/modules/kanban/main.coffee index 1d95219e..7b875662 100644 --- a/app/coffee/modules/kanban/main.coffee +++ b/app/coffee/modules/kanban/main.coffee @@ -34,26 +34,18 @@ bindMethods = @.taiga.bindMethods module = angular.module("taigaKanban") -# Vars - -defaultViewMode = "maximized" -viewModes = [ - "maximized", - "minimized" -] - - ############################################################################# ## Kanban Controller ############################################################################# -class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin) +class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin, taiga.UsFiltersMixin) @.$inject = [ "$scope", "$rootScope", "$tgRepo", "$tgConfirm", "$tgResources", + "tgResources", "$routeParams", "$q", "$tgLocation", @@ -62,16 +54,26 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi "$tgEvents", "$tgAnalytics", "$translate", - "tgErrorHandlingService" + "tgErrorHandlingService", + "$tgModel", + "tgKanbanUserstories", + "$tgStorage", + "tgFilterRemoteStorageService" ] - constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @location, - @appMetaService, @navUrls, @events, @analytics, @translate, @errorHandlingService) -> + 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) -> bindMethods(@) + @kanbanUserstoriesService.reset() + @.openFilter = false + + return if @.applyStoredFilters(@params.pslug, "kanban-filters") @scope.sectionName = @translate.instant("KANBAN.SECTION_NAME") - @scope.statusViewModes = {} @.initializeEventHandlers() promise = @.loadInitialData() @@ -88,80 +90,106 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi # On Error promise.then null, @.onInitialDataError.bind(@) + taiga.defineImmutableProperty @.scope, "usByStatus", () => + return @kanbanUserstoriesService.usByStatus + + setZoom: (zoomLevel, zoom) -> + if @.zoomLevel != zoomLevel + @kanbanUserstoriesService.resetFolds() + + @.zoomLevel = zoomLevel + @.zoom = zoom + + filtersReloadContent: () -> + @.loadUserstories().then () => + openArchived = _.difference(@kanbanUserstoriesService.archivedStatus, @kanbanUserstoriesService.statusHide) + if openArchived.length + for statusId in openArchived + @.loadUserStoriesForStatus({}, statusId) + initializeEventHandlers: -> - @scope.$on "usform:new:success", => - @.loadUserstories() - @.refreshTagsColors() + @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", => - @.loadUserstories() + @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", => - @.loadUserstories() - @.refreshTagsColors() + @scope.$on "usform:edit:success", (event, us) => + @.refreshTagsColors().then () => + @kanbanUserstoriesService.replaceModel(us) @scope.$on("assigned-to:added", @.onAssignedToChanged) @scope.$on("kanban:us:move", @.moveUs) @scope.$on("kanban:show-userstories-for-status", @.loadUserStoriesForStatus) @scope.$on("kanban:hide-userstories-for-status", @.hideUserStoriesForStatus) - # Template actions - addNewUs: (type, statusId) -> switch type when "standard" then @rootscope.$broadcast("usform:new", @scope.projectId, statusId, @scope.usStatusList) when "bulk" then @rootscope.$broadcast("usform:bulk", @scope.projectId, statusId) - changeUsAssignedTo: (us) -> + editUs: (id) -> + us = @kanbanUserstoriesService.getUs(id) + us = us.set('loading', 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("usform:edit", editingUserStory, attachments.toJS()) + + us = us.set('loading', false) + @kanbanUserstoriesService.replace(us) + + 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) - # Scope Events Handlers + onAssignedToChanged: (ctx, userid, usModel) -> + usModel.assigned_to = userid - onAssignedToChanged: (ctx, userid, us) -> - us.assigned_to = userid + @kanbanUserstoriesService.replaceModel(usModel) - promise = @repo.save(us) + promise = @repo.save(usModel) promise.then null, -> console.log "FAIL" # TODO - # Load data methods refreshTagsColors: -> return @rs.projects.tagsColors(@scope.projectId).then (tags_colors) => @scope.project.tags_colors = tags_colors loadUserstories: -> params = { - status__is_archived: false + status__is_archived: false, + include_attachments: true, + include_tasks: true } + params = _.merge params, @location.search() + promise = @rs.userstories.listAll(@scope.projectId, params).then (userstories) => - @scope.userstories = userstories - - usByStatus = _.groupBy(userstories, "status") - us_archived = [] - for status in @scope.usStatusList - if not usByStatus[status.id]? - usByStatus[status.id] = [] - if @scope.usByStatus? - for us in @scope.usByStatus[status.id] - if us.status != status.id - us_archived.push(us) - - # Must preserve the archived columns if loaded - if status.is_archived and @scope.usByStatus? and @scope.usByStatus[status.id].length != 0 - for us in @scope.usByStatus[status.id].concat(us_archived) - if us.status == status.id - usByStatus[status.id].push(us) - - usByStatus[status.id] = _.sortBy(usByStatus[status.id], "kanban_order") - - if userstories.length == 0 - status = @scope.usStatusList[0] - usByStatus[status.id].push({isPlaceholder: true}) - - @scope.usByStatus = usByStatus + @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 @@ -175,14 +203,28 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi return promise loadUserStoriesForStatus: (ctx, statusId) -> - params = { status: 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.usByStatus[statusId] = _.sortBy(userstories, "kanban_order") @scope.$broadcast("kanban:shown-userstories-for-status", statusId, userstories) + return userstories hideUserStoriesForStatus: (ctx, statusId) -> - @scope.usByStatus[statusId] = [] @scope.$broadcast("kanban:hidden-userstories-for-status", statusId) loadKanban: -> @@ -204,8 +246,6 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi @scope.usStatusById = groupBy(project.us_statuses, (x) -> x.id) @scope.usStatusList = _.sortBy(project.us_statuses, "order") - @.generateStatusViewModes() - @scope.$emit("project:loaded", project) return project @@ -220,82 +260,40 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi @.fillUsersAndRoles(project.members, project.roles) @.initializeSubscription() @.loadKanban() - - - ## View Mode methods - - generateStatusViewModes: -> - storedStatusViewModes = @rs.kanban.getStatusViewModes(@scope.projectId) - - @scope.statusViewModes = {} - for status in @scope.usStatusList - mode = storedStatusViewModes[status.id] || defaultViewMode - - @scope.statusViewModes[status.id] = mode - - @.storeStatusViewModes() - - storeStatusViewModes: -> - @rs.kanban.storeStatusViewModes(@scope.projectId, @scope.statusViewModes) - - updateStatusViewMode: (statusId, newViewMode) -> - @scope.statusViewModes[statusId] = newViewMode - @.storeStatusViewModes() - - isMaximized: (statusId) -> - mode = @scope.statusViewModes[statusId] or defaultViewMode - return mode == 'maximized' - - isMinimized: (statusId) -> - mode = @scope.statusViewModes[statusId] or defaultViewMode - return mode == 'minimized' + @.generateFilters() # Utils methods prepareBulkUpdateData: (uses, field="kanban_order") -> return _.map(uses, (x) -> {"us_id": x.id, "order": x[field]}) - resortUserStories: (uses) -> - items = [] - for item, index in uses - item.kanban_order = index - if item.isModified() - items.push(item) - - return items - moveUs: (ctx, us, oldStatusId, newStatusId, index) -> - if oldStatusId != newStatusId - # Remove us from old status column - r = @scope.usByStatus[oldStatusId].indexOf(us) - @scope.usByStatus[oldStatusId].splice(r, 1) + us = @kanbanUserstoriesService.getUsModel(us.get('id')) - # Add us to new status column. - @scope.usByStatus[newStatusId].splice(index, 0, us) - us.status = newStatusId - else - r = @scope.usByStatus[newStatusId].indexOf(us) - @scope.usByStatus[newStatusId].splice(r, 1) - @scope.usByStatus[newStatusId].splice(index, 0, us) + moveUpdateData = @kanbanUserstoriesService.move(us.id, newStatusId, index) - itemsToSave = @.resortUserStories(@scope.usByStatus[newStatusId]) - @scope.usByStatus[newStatusId] = _.sortBy(@scope.usByStatus[newStatusId], "kanban_order") + params = { + include_attachments: true, + include_tasks: true + } - # Persist the userstory - promise = @repo.save(us) + options = { + headers: { + "set-orders": JSON.stringify(moveUpdateData.set_orders) + } + } - # Rehash userstories order field - # and persist in bulk all changes. - promise = promise.then => - itemsToSave = _.reject(itemsToSave, {"id": us.id}) - data = @.prepareBulkUpdateData(itemsToSave) + promise = @repo.save(us, true, params, options, true) - return @rs.userstories.bulkUpdateKanbanOrder(us.project, data).then => - return itemsToSave + promise = promise.then (result) => + headers = result[1] + + if headers && headers['taiga-info-order-updated'] + order = JSON.parse(headers['taiga-info-order-updated']) + @kanbanUserstoriesService.assignOrders(order) return promise - module.controller("KanbanController", KanbanController) ############################################################################# @@ -322,7 +320,7 @@ module.directive("tgKanban", ["$tgRepo", "$rootScope", KanbanDirective]) ## Kanban Archived Status Column Header Control ############################################################################# -KanbanArchivedStatusHeaderDirective = ($rootscope, $translate) -> +KanbanArchivedStatusHeaderDirective = ($rootscope, $translate, kanbanUserstoriesService) -> showArchivedText = $translate.instant("KANBAN.ACTION_SHOW_ARCHIVED") hideArchivedText = $translate.instant("KANBAN.ACTION_HIDE_ARCHIVED") @@ -330,6 +328,9 @@ KanbanArchivedStatusHeaderDirective = ($rootscope, $translate) -> status = $scope.$eval($attrs.tgKanbanArchivedStatusHeader) hidden = true + kanbanUserstoriesService.addArchivedStatus(status.id) + kanbanUserstoriesService.hideStatus(status.id) + $scope.class = "icon-watch" $scope.title = showArchivedText @@ -342,24 +343,27 @@ KanbanArchivedStatusHeaderDirective = ($rootscope, $translate) -> $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", KanbanArchivedStatusHeaderDirective]) +module.directive("tgKanbanArchivedStatusHeader", [ "$rootScope", "$translate", "tgKanbanUserstories", KanbanArchivedStatusHeaderDirective]) ############################################################################# ## Kanban Archived Status Column Intro Directive ############################################################################# -KanbanArchivedStatusIntroDirective = ($translate) -> +KanbanArchivedStatusIntroDirective = ($translate, kanbanUserstoriesService) -> userStories = [] link = ($scope, $el, $attrs) -> @@ -367,105 +371,40 @@ KanbanArchivedStatusIntroDirective = ($translate) -> status = $scope.$eval($attrs.tgKanbanArchivedStatusIntro) $el.text(hiddenUserStoriexText) - updateIntroText = -> - if userStories.length > 0 + updateIntroText = (hasArchived) -> + if hasArchived $el.text("") else $el.text(hiddenUserStoriexText) $scope.$on "kanban:us:move", (ctx, itemUs, oldStatusId, newStatusId, itemIndex) -> - # The destination columnd is this one - if status.id == newStatusId - # Reorder - if status.id == oldStatusId - r = userStories.indexOf(itemUs) - userStories.splice(r, 1) - userStories.splice(itemIndex, 0, itemUs) - - # Archiving user story - else - itemUs.isArchived = true - userStories.splice(itemIndex, 0, itemUs) - - # Unarchiving user story - else if status.id == oldStatusId - itemUs.isArchived = false - r = userStories.indexOf(itemUs) - userStories.splice(r, 1) - - updateIntroText() + hasArchived = !!kanbanUserstoriesService.getStatus(newStatusId).length + updateIntroText(hasArchived) $scope.$on "kanban:shown-userstories-for-status", (ctx, statusId, userStoriesLoaded) -> if statusId == status.id - userStories = _.filter(userStoriesLoaded, (us) -> us.status == status.id) - updateIntroText() + 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 - userStories = [] - updateIntroText() + updateIntroText(false) $scope.$on "$destroy", -> $el.off() return {link:link} -module.directive("tgKanbanArchivedStatusIntro", ["$translate", KanbanArchivedStatusIntroDirective]) - - -############################################################################# -## Kanban User Story Directive -############################################################################# - -KanbanUserstoryDirective = ($rootscope, $loading, $rs, $rs2) -> - link = ($scope, $el, $attrs, $model) -> - $scope.$watch "us", (us) -> - if us.is_blocked and not $el.hasClass("blocked") - $el.addClass("blocked") - else if not us.is_blocked and $el.hasClass("blocked") - $el.removeClass("blocked") - - $el.on 'click', '.edit-us', (event) -> - if $el.find(".icon-edit").hasClass("noclick") - return - - target = $(event.target) - - currentLoading = $loading() - .target(target) - .timeout(200) - .removeClasses("icon-edit") - .start() - - us = $model.$modelValue - $rs.userstories.getByRef(us.project, us.ref).then (editingUserStory) => - $rs2.attachments.list("us", us.id, us.project).then (attachments) => - $rootscope.$broadcast("usform:edit", editingUserStory, attachments.toJS()) - currentLoading.finish() - - $scope.getTemplateUrl = () -> - if $scope.us.isPlaceholder - return "common/components/kanban-placeholder.html" - else - return "kanban/kanban-task.html" - - $scope.$on "$destroy", -> - $el.off() - - return { - template: '', - link: link - require: "ngModel" - } - -module.directive("tgKanbanUserstory", ["$rootScope", "$tgLoading", "$tgResources", "tgResources", KanbanUserstoryDirective]) +module.directive("tgKanbanArchivedStatusIntro", ["$translate", "tgKanbanUserstories", KanbanArchivedStatusIntroDirective]) ############################################################################# ## Kanban Squish Column Directive ############################################################################# KanbanSquishColumnDirective = (rs) -> - link = ($scope, $el, $attrs) -> $scope.$on "project:loaded", (event, project) -> $scope.folds = rs.kanban.getStatusColumnModes(project.id) @@ -485,6 +424,7 @@ KanbanSquishColumnDirective = (rs) -> return 310 totalWidth = _.reduce columnWidths, (total, width) -> return total + width + $el.find('.kanban-table-inner').css("width", totalWidth) return {link: link} @@ -502,7 +442,7 @@ KanbanWipLimitDirective = -> redrawWipLimit = => $el.find(".kanban-wip-limit").remove() timeout 200, => - element = $el.find(".kanban-task")[status.wip_limit] + element = $el.find("tg-card")[status.wip_limit] if element angular.element(element).before("
") @@ -518,83 +458,3 @@ KanbanWipLimitDirective = -> return {link: link} module.directive("tgKanbanWipLimit", KanbanWipLimitDirective) - - -############################################################################# -## Kanban User Directive -############################################################################# - -KanbanUserDirective = ($log, $compile, $translate, avatarService) -> - template = _.template(""" -
- class="not-clickable"<% } %>> - <%- name %> - -
- """) - - clickable = false - - link = ($scope, $el, $attrs, $model) -> - username_label = $el.parent().find("a.task-assigned") - username_label.addClass("not-clickable") - - if not $attrs.tgKanbanUserAvatar - return $log.error "KanbanUserDirective: no attr is defined" - - wtid = $scope.$watch $attrs.tgKanbanUserAvatar, (v) -> - if not $scope.usersById? - $log.error "KanbanUserDirective requires userById set in scope." - wtid() - else - user = $scope.usersById[v] - render(user) - - render = (user) -> - avatar = avatarService.getAvatar(user) - - if user is undefined - ctx = { - name: $translate.instant("COMMON.ASSIGNED_TO.NOT_ASSIGNED"), - imgurl: avatar.url, - clickable: clickable, - bg: null - } - else - ctx = { - name: user.full_name_display, - imgurl: avatar.url, - bg: avatar.bg, - clickable: clickable - } - - html = $compile(template(ctx))($scope) - $el.html(html) - username_label.text(ctx.name) - - bindOnce $scope, "project", (project) -> - if project.my_permissions.indexOf("modify_us") > -1 - clickable = true - $el.on "click", (event) => - if $el.find("a").hasClass("noclick") - return - - us = $model.$modelValue - $ctrl = $el.controller() - $ctrl.changeUsAssignedTo(us) - - username_label.removeClass("not-clickable") - username_label.on "click", (event) -> - if $el.find("a").hasClass("noclick") - return - - us = $model.$modelValue - $ctrl = $el.controller() - $ctrl.changeUsAssignedTo(us) - - $scope.$on "$destroy", -> - $el.off() - - return {link: link, require:"ngModel"} - -module.directive("tgKanbanUserAvatar", ["$log", "$compile", "$translate", "tgAvatarService", KanbanUserDirective]) diff --git a/app/coffee/modules/kanban/sortable.coffee b/app/coffee/modules/kanban/sortable.coffee index bcdd55d6..0ae86a41 100644 --- a/app/coffee/modules/kanban/sortable.coffee +++ b/app/coffee/modules/kanban/sortable.coffee @@ -40,8 +40,12 @@ module = angular.module("taigaKanban") KanbanSortableDirective = ($repo, $rs, $rootscope) -> link = ($scope, $el, $attrs) -> - bindOnce $scope, "project", (project) -> - if not (project.my_permissions.indexOf("modify_us") > -1) + unwatch = $scope.$watch "usByStatus", (usByStatus) -> + return if !usByStatus || !usByStatus.size + + unwatch() + + if not ($scope.project.my_permissions.indexOf("modify_us") > -1) return oldParentScope = null @@ -63,7 +67,7 @@ KanbanSortableDirective = ($repo, $rs, $rootscope) -> copy: false, mirrorContainer: tdom[0], moves: (item) -> - return $(item).hasClass('kanban-task') + return $(item).is('tg-card') }) drake.on 'drag', (item) -> @@ -83,7 +87,7 @@ KanbanSortableDirective = ($repo, $rs, $rootscope) -> deleteElement(itemEl) $scope.$apply -> - $rootscope.$broadcast("kanban:us:move", itemUs, itemUs.status, newStatusId, itemIndex) + $rootscope.$broadcast("kanban:us:move", itemUs, itemUs.getIn(['model', 'status']), newStatusId, itemIndex) scroll = autoScroll(containers, { margin: 100, diff --git a/app/coffee/modules/resources.coffee b/app/coffee/modules/resources.coffee index 8d30fb0f..59e8743a 100644 --- a/app/coffee/modules/resources.coffee +++ b/app/coffee/modules/resources.coffee @@ -96,7 +96,8 @@ urls = { "userstories": "/userstories" "bulk-create-us": "/userstories/bulk_create" "bulk-update-us-backlog-order": "/userstories/bulk_update_backlog_order" - "bulk-update-us-sprint-order": "/userstories/bulk_update_sprint_order" + "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" "userstories-filters": "/userstories/filters_data" "userstory-upvote": "/userstories/%s/upvote" @@ -112,6 +113,7 @@ urls = { "task-downvote": "/tasks/%s/downvote" "task-watch": "/tasks/%s/watch" "task-unwatch": "/tasks/%s/unwatch" + "task-filters": "/tasks/filters_data" # Issues "issues": "/issues" diff --git a/app/coffee/modules/resources/issues.coffee b/app/coffee/modules/resources/issues.coffee index 1568b035..60ea24b6 100644 --- a/app/coffee/modules/resources/issues.coffee +++ b/app/coffee/modules/resources/issues.coffee @@ -30,8 +30,6 @@ generateHash = taiga.generateHash resourceProvider = ($repo, $http, $urls, $storage, $q) -> service = {} hashSuffix = "issues-queryparams" - filtersHashSuffix = "issues-filters" - myFiltersHashSuffix = "issues-my-filters" service.get = (projectId, issueId) -> params = service.getQueryParams(projectId) @@ -95,53 +93,6 @@ resourceProvider = ($repo, $http, $urls, $storage, $q) -> hash = generateHash([projectId, ns]) return $storage.get(hash) or {} - service.storeFilters = (projectSlug, params) -> - ns = "#{projectSlug}:#{filtersHashSuffix}" - hash = generateHash([projectSlug, ns]) - $storage.set(hash, params) - - service.getFilters = (projectSlug) -> - ns = "#{projectSlug}:#{filtersHashSuffix}" - hash = generateHash([projectSlug, ns]) - return $storage.get(hash) or {} - - service.storeMyFilters = (projectId, myFilters) -> - deferred = $q.defer() - url = $urls.resolve("user-storage") - ns = "#{projectId}:#{myFiltersHashSuffix}" - hash = generateHash([projectId, ns]) - if _.isEmpty(myFilters) - promise = $http.delete("#{url}/#{hash}", {key: hash, value:myFilters}) - promise.then -> - deferred.resolve() - promise.then null, -> - deferred.reject() - else - promise = $http.put("#{url}/#{hash}", {key: hash, value:myFilters}) - promise.then (data) -> - deferred.resolve() - promise.then null, (data) -> - innerPromise = $http.post("#{url}", {key: hash, value:myFilters}) - innerPromise.then -> - deferred.resolve() - innerPromise.then null, -> - deferred.reject() - return deferred.promise - - service.getMyFilters = (projectId) -> - deferred = $q.defer() - url = $urls.resolve("user-storage") - ns = "#{projectId}:#{myFiltersHashSuffix}" - hash = generateHash([projectId, ns]) - - promise = $http.get("#{url}/#{hash}") - promise.then (data) -> - deferred.resolve(data.data.value) - promise.then null, (data) -> - deferred.resolve({}) - - return deferred.promise - return (instance) -> instance.issues = service diff --git a/app/coffee/modules/resources/kanban.coffee b/app/coffee/modules/resources/kanban.coffee index a79bee06..48bd2074 100644 --- a/app/coffee/modules/resources/kanban.coffee +++ b/app/coffee/modules/resources/kanban.coffee @@ -32,16 +32,6 @@ resourceProvider = ($storage) -> hashSuffixStatusViewModes = "kanban-statusviewmodels" hashSuffixStatusColumnModes = "kanban-statuscolumnmodels" - service.storeStatusViewModes = (projectId, params) -> - ns = "#{projectId}:#{hashSuffixStatusViewModes}" - hash = generateHash([projectId, ns]) - $storage.set(hash, params) - - service.getStatusViewModes = (projectId) -> - ns = "#{projectId}:#{hashSuffixStatusViewModes}" - hash = generateHash([projectId, ns]) - return $storage.get(hash) or {} - service.storeStatusColumnModes = (projectId, params) -> ns = "#{projectId}:#{hashSuffixStatusColumnModes}" hash = generateHash([projectId, ns]) diff --git a/app/coffee/modules/resources/tasks.coffee b/app/coffee/modules/resources/tasks.coffee index 6a838a5d..ba27fec7 100644 --- a/app/coffee/modules/resources/tasks.coffee +++ b/app/coffee/modules/resources/tasks.coffee @@ -38,17 +38,23 @@ resourceProvider = ($repo, $http, $urls, $storage) -> params.project = projectId return $repo.queryOne("tasks", taskId, params) - service.getByRef = (projectId, ref) -> + service.getByRef = (projectId, ref, extraParams) -> params = service.getQueryParams(projectId) params.project = projectId params.ref = ref + + params = _.extend({}, params, extraParams) + return $repo.queryOne("tasks", "by_ref", params) service.listInAllProjects = (filters) -> return $repo.queryMany("tasks", filters) - service.list = (projectId, sprintId=null, userStoryId=null) -> - params = {project: projectId} + service.filtersData = (params) -> + return $repo.queryOneRaw("task-filters", null, params) + + service.list = (projectId, sprintId=null, userStoryId=null, params) -> + params = _.merge(params, {project: projectId}) params.milestone = sprintId if sprintId params.user_story = userStoryId if userStoryId service.storeQueryParams(projectId, params) diff --git a/app/coffee/modules/resources/userstories.coffee b/app/coffee/modules/resources/userstories.coffee index 935f6d3a..f4c5cca4 100644 --- a/app/coffee/modules/resources/userstories.coffee +++ b/app/coffee/modules/resources/userstories.coffee @@ -26,7 +26,7 @@ taiga = @.taiga generateHash = taiga.generateHash -resourceProvider = ($repo, $http, $urls, $storage) -> +resourceProvider = ($repo, $http, $urls, $storage, $q) -> service = {} hashSuffix = "userstories-queryparams" @@ -35,10 +35,12 @@ resourceProvider = ($repo, $http, $urls, $storage) -> params.project = projectId return $repo.queryOne("userstories", usId, params) - service.getByRef = (projectId, ref) -> + service.getByRef = (projectId, ref, extraParams = {}) -> params = service.getQueryParams(projectId) params.project = projectId params.ref = ref + params = _.extend({}, params, extraParams) + return $repo.queryOne("userstories", "by_ref", params) service.listInAllProjects = (filters) -> @@ -96,9 +98,9 @@ resourceProvider = ($repo, $http, $urls, $storage) -> params = {project_id: projectId, bulk_stories: data} return $http.post(url, params) - service.bulkUpdateSprintOrder = (projectId, data) -> - url = $urls.resolve("bulk-update-us-sprint-order") - params = {project_id: projectId, bulk_stories: data} + service.bulkUpdateMilestone = (projectId, milestoneId, data) -> + url = $urls.resolve("bulk-update-us-milestone") + params = {project_id: projectId, milestone_id: milestoneId, bulk_stories: data} return $http.post(url, params) service.bulkUpdateKanbanOrder = (projectId, data) -> @@ -133,4 +135,4 @@ resourceProvider = ($repo, $http, $urls, $storage) -> instance.userstories = service module = angular.module("taigaResources") -module.factory("$tgUserstoriesResourcesProvider", ["$tgRepo", "$tgHttp", "$tgUrls", "$tgStorage", resourceProvider]) +module.factory("$tgUserstoriesResourcesProvider", ["$tgRepo", "$tgHttp", "$tgUrls", "$tgStorage", "$q", resourceProvider]) diff --git a/app/coffee/modules/taskboard/lightboxes.coffee b/app/coffee/modules/taskboard/lightboxes.coffee index 756d84e8..ad052026 100644 --- a/app/coffee/modules/taskboard/lightboxes.coffee +++ b/app/coffee/modules/taskboard/lightboxes.coffee @@ -108,6 +108,11 @@ CreateEditTaskDirective = ($repo, $model, $rs, $rootscope, $loading, lightboxSer if not form.validate() return + params = { + include_attachments: true, + include_tasks: true + } + if $scope.isNew promise = $repo.create("tasks", $scope.task) broadcastEvent = "taskform:new:success" @@ -116,20 +121,22 @@ CreateEditTaskDirective = ($repo, $model, $rs, $rootscope, $loading, lightboxSer broadcastEvent = "taskform:edit:success" promise.then (data) -> - createAttachments(data) deleteAttachments(data) + .then () => createAttachments(data) + .then () => + currentLoading.finish() + lightboxService.close($el) - return data + $rs.tasks.getByRef(data.project, data.ref, params).then (task) -> + $rootscope.$broadcast(broadcastEvent, task) currentLoading = $loading() .target(submitButton) .start() - # FIXME: error handling? promise.then (data) -> currentLoading.finish() lightboxService.close($el) - $rootscope.$broadcast(broadcastEvent, data) $el.on "submit", "form", submit @@ -139,7 +146,7 @@ CreateEditTaskDirective = ($repo, $model, $rs, $rootscope, $loading, lightboxSer return {link: link} -CreateBulkTasksDirective = ($repo, $rs, $rootscope, $loading, lightboxService) -> +CreateBulkTasksDirective = ($repo, $rs, $rootscope, $loading, lightboxService, $model) -> link = ($scope, $el, attrs) -> $scope.form = {data: "", usId: null} @@ -161,6 +168,7 @@ CreateBulkTasksDirective = ($repo, $rs, $rootscope, $loading, lightboxService) - promise = $rs.tasks.bulkCreate(projectId, sprintId, usId, data) promise.then (result) -> + result = _.map(result, (x) => $model.make_model('userstories', x)) currentLoading.finish() $rootscope.$broadcast("taskform:bulk:success", result) lightboxService.close($el) @@ -205,5 +213,6 @@ module.directive("tgLbCreateBulkTasks", [ "$rootScope", "$tgLoading", "lightboxService", + "$tgModel", CreateBulkTasksDirective ]) diff --git a/app/coffee/modules/taskboard/main.coffee b/app/coffee/modules/taskboard/main.coffee index 15b3b753..b66d6957 100644 --- a/app/coffee/modules/taskboard/main.coffee +++ b/app/coffee/modules/taskboard/main.coffee @@ -38,13 +38,14 @@ module = angular.module("taigaTaskboard") ## Taskboard Controller ############################################################################# -class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin) +class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.FiltersMixin) @.$inject = [ "$scope", "$rootScope", "$tgRepo", "$tgConfirm", "$tgResources", + "tgResources" "$routeParams", "$q", "tgAppMetaService", @@ -53,12 +54,20 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin) "$tgEvents" "$tgAnalytics", "$translate", - "tgErrorHandlingService" + "tgErrorHandlingService", + "tgTaskboardTasks", + "$tgStorage", + "tgFilterRemoteStorageService" ] - constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @appMetaService, @location, @navUrls, - @events, @analytics, @translate, @errorHandlingService) -> + constructor: (@scope, @rootscope, @repo, @confirm, @rs, @rs2, @params, @q, @appMetaService, @location, @navUrls, + @events, @analytics, @translate, @errorHandlingService, @taskboardTasksService, @storage, @filterRemoteStorageService) -> bindMethods(@) + @taskboardTasksService.reset() + @scope.userstories = [] + @.openFilter = false + + return if @.applyStoredFilters(@params.pslug, "tasks-filters") @scope.sectionName = @translate.instant("TASKBOARD.SECTION_NAME") @.initializeEventHandlers() @@ -70,6 +79,150 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin) # On Error promise.then null, @.onInitialDataError.bind(@) + taiga.defineImmutableProperty @.scope, "usTasks", () => + return @taskboardTasksService.usTasks + + setZoom: (zoomLevel, zoom) -> + if @.zoomLevel != zoomLevel + @taskboardTasksService.resetFolds() + + @.zoomLevel = zoomLevel + @.zoom = zoom + + if @.zoomLevel == '0' + @rootscope.$broadcast("sprint:zoom0") + + changeQ: (q) -> + @.replaceFilter("q", q) + @.loadTasks() + @.generateFilters() + + removeFilter: (filter) -> + @.unselectFilter(filter.dataType, filter.id) + @.loadTasks() + @.generateFilters() + + addFilter: (newFilter) -> + @.selectFilter(newFilter.category.dataType, newFilter.filter.id) + @.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) + + saveCustomFilter: (name) -> + filters = {} + urlfilters = @location.search() + filters.tags = urlfilters.tags + filters.status = urlfilters.status + filters.assigned_to = urlfilters.assigned_to + filters.owner = urlfilters.owner + + @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.tags = urlfilters.tags + loadFilters.status = urlfilters.status + loadFilters.assigned_to = urlfilters.assigned_to + loadFilters.owner = urlfilters.owner + loadFilters.q = urlfilters.q + + return @q.all([ + @rs.tasks.filtersData(loadFilters), + @filterRemoteStorageService.getFilters(@scope.projectId, 'tasks-custom-filters') + ]).then (result) => + data = result[0] + customFiltersRaw = result[1] + + statuses = _.map data.statuses, (it) -> + it.id = it.id.toString() + + return it + tags = _.map data.tags, (it) -> + it.id = it.name + + return it + assignedTo = _.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 + owner = _.map data.owners, (it) -> + it.id = it.id.toString() + it.name = it.full_name + + return it + + @.selectedFilters = [] + + if loadFilters.status + selected = @.formatSelectedFilters("status", statuses, loadFilters.status) + @.selectedFilters = @.selectedFilters.concat(selected) + + if loadFilters.tags + selected = @.formatSelectedFilters("tags", tags, loadFilters.tags) + @.selectedFilters = @.selectedFilters.concat(selected) + + if loadFilters.assigned_to + selected = @.formatSelectedFilters("assigned_to", assignedTo, loadFilters.assigned_to) + @.selectedFilters = @.selectedFilters.concat(selected) + + if loadFilters.owner + selected = @.formatSelectedFilters("owner", owner, loadFilters.owner) + @.selectedFilters = @.selectedFilters.concat(selected) + + @.filterQ = loadFilters.q + + @.filters = [ + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.STATUS"), + dataType: "status", + content: statuses + }, + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.TAGS"), + dataType: "tags", + content: tags, + hideEmpty: true + }, + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.ASSIGNED_TO"), + dataType: "assigned_to", + content: assignedTo + }, + { + title: @translate.instant("COMMON.FILTERS.CATEGORIES.CREATED_BY"), + dataType: "owner", + content: owner + } + ]; + + @.customFilters = [] + _.forOwn customFiltersRaw, (value, key) => + @.customFilters.push({id: key, name: key, filter: value}) + _setMeta: -> prettyDate = @translate.instant("BACKLOG.SPRINTS.DATE") @@ -92,24 +245,33 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin) @appMetaService.setAll(title, description) initializeEventHandlers: -> - # TODO: Reload entire taskboard after create/edit tasks seems - # a big overhead. It should be optimized in near future. - @scope.$on "taskform:bulk:success", => - @.loadTaskboard() + @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", => - @.loadTaskboard() + @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", => @.loadTaskboard()) - @scope.$on("taskboard:task:move", @.taskMove) + @scope.$on "taskform:edit:success", (event, task) => + @.refreshTagsColors().then () => + @taskboardTasksService.replaceModel(task) - @scope.$on "assigned-to:added", (ctx, userId, task) => - task.assigned_to = userId - promise = @repo.save(task) - promise.then null, -> - console.log "FAIL" # TODO + @scope.$on("taskboard:task:move", @.taskMove) + @scope.$on("assigned-to:added", @.onAssignedToChanged) + + onAssignedToChanged: (ctx, userid, taskModel) -> + taskModel.assigned_to = userid + + @taskboardTasksService.replaceModel(taskModel) + + promise = @repo.save(taskModel) + promise.then null, -> + console.log "FAIL" # TODO initializeSubscription: -> routingKey = "changes.project.#{@scope.projectId}.tasks" @@ -130,7 +292,6 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin) @scope.project = project # Not used at this momment @scope.pointsList = _.sortBy(project.points, "order") - # @scope.roleList = _.sortBy(project.roles, "order") @scope.pointsById = groupBy(project.points, (e) -> e.id) @scope.roleById = groupBy(project.roles, (e) -> e.id) @scope.taskStatusList = _.sortBy(project.task_statuses, "order") @@ -170,34 +331,22 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin) 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 loadTasks: -> - return @rs.tasks.list(@scope.projectId, @scope.sprintId).then (tasks) => - @scope.tasks = _.sortBy(tasks, 'taskboard_order') - @scope.usTasks = {} + params = { + include_attachments: true, + include_tasks: true + } - # Iterate over all userstories and - # null userstory for unassigned tasks - for us in _.union(@scope.userstories, [{id:null}]) - @scope.usTasks[us.id] = {} - for status in @scope.taskStatusList - @scope.usTasks[us.id][status.id] = [] + params = _.merge params, @location.search() - for task in @scope.tasks - if @scope.usTasks[task.user_story]? and @scope.usTasks[task.user_story][task.status]? - @scope.usTasks[task.user_story][task.status].push(task) - - if tasks.length == 0 - - if @scope.userstories.length > 0 - usId = @scope.userstories[0].id - else - usId = null - - @scope.usTasks[usId][@scope.taskStatusList[0].id].push({isPlaceholder: true}) - - return tasks + 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([ @@ -219,59 +368,69 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin) return data return promise.then(=> @.loadProject()) - .then(=> @.loadTaskboard()) - .then(=> @.setRolePoints()) + .then => + @.generateFilters() - refreshTasksOrder: (tasks) -> - items = @.resortTasks(tasks) - data = @.prepareBulkUpdateData(items) + return @.loadTaskboard().then(=> @.setRolePoints()) - return @rs.tasks.bulkUpdateTaskTaskboardOrder(@scope.project.id, data) + showPlaceHolder: (statusId, usId) -> + if !@taskboardTasksService.tasksRaw.length + if @scope.taskStatusList[0].id == statusId && + (!@scope.userstories.length || @scope.userstories[0].id == usId) + return true - resortTasks: (tasks) -> - items = [] + return false - for item, index in tasks - item["taskboard_order"] = index - if item.isModified() - items.push(item) + editTask: (id) -> + task = @.taskboardTasksService.getTask(id) - return items + task = task.set('loading', true) + @taskboardTasksService.replace(task) - prepareBulkUpdateData: (uses) -> - return _.map(uses, (x) -> {"task_id": x.id, "order": x["taskboard_order"]}) + @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("taskform:edit", editingTask, attachments.toJS()) + task = task.set('loading', false) + @taskboardTasksService.replace(task) - taskMove: (ctx, task, usId, statusId, order) -> - # Remove task from old position - r = @scope.usTasks[task.user_story][task.status].indexOf(task) - @scope.usTasks[task.user_story][task.status].splice(r, 1) + taskMove: (ctx, task, oldStatusId, usId, statusId, order) -> + task = @taskboardTasksService.getTaskModel(task.get('id')) - # Add task to new position - tasks = @scope.usTasks[usId][statusId] - tasks.splice(order, 0, task) + moveUpdateData = @taskboardTasksService.move(task.id, usId, statusId, order) - task.user_story = usId - task.status = statusId - task.taskboard_order = order + params = { + status__is_archived: false, + include_attachments: true, + include_tasks: true + } - promise = @repo.save(task) + options = { + headers: { + "set-orders": JSON.stringify(moveUpdateData.set_orders) + } + } - @rootscope.$broadcast("sprint:task:moved", task) + promise = @repo.save(task, true, params, options, true).then (result) => + headers = result[1] + + if headers && headers['taiga-info-order-updated'] + order = JSON.parse(headers['taiga-info-order-updated']) + @taskboardTasksService.assignOrders(order) - promise.then => - @.refreshTasksOrder(tasks) @.loadSprintStats() - promise.then null, => - console.log "FAIL TASK SAVE" - ## Template actions addNewTask: (type, us) -> switch type when "standard" then @rootscope.$broadcast("taskform:new", @scope.sprintId, us?.id) when "bulk" then @rootscope.$broadcast("taskform:bulk", @scope.sprintId, us?.id) - editTaskAssignedTo: (task) -> + toggleFold: (id) -> + @taskboardTasksService.toggleFold(id) + + changeTaskAssignedTo: (id) -> + task = @taskboardTasksService.getTaskModel(id) + @rootscope.$broadcast("assigned-to:add", task) setRolePoints: () -> @@ -331,43 +490,6 @@ TaskboardDirective = ($rootscope) -> module.directive("tgTaskboard", ["$rootScope", TaskboardDirective]) - -############################################################################# -## Taskboard Task Directive -############################################################################# - -TaskboardTaskDirective = ($rootscope, $loading, $rs, $rs2) -> - link = ($scope, $el, $attrs, $model) -> - $scope.$watch "task", (task) -> - if task.is_blocked and not $el.hasClass("blocked") - $el.addClass("blocked") - else if not task.is_blocked and $el.hasClass("blocked") - $el.removeClass("blocked") - - $el.find(".edit-task").on "click", (event) -> - if $el.find('.edit-task').hasClass('noclick') - return - - $scope.$apply -> - target = $(event.target) - - currentLoading = $loading() - .target(target) - .timeout(200) - .start() - - task = $scope.task - - $rs.tasks.getByRef(task.project, task.ref).then (editingTask) => - $rs2.attachments.list("task", editingTask.id, editingTask.project).then (attachments) => - $rootscope.$broadcast("taskform:edit", editingTask, attachments.toJS()) - currentLoading.finish() - - return {link:link} - - -module.directive("tgTaskboardTask", ["$rootScope", "$tgLoading", "$tgResources", "tgResources", TaskboardTaskDirective]) - ############################################################################# ## Taskboard Squish Column Directive ############################################################################# @@ -377,14 +499,18 @@ TaskboardSquishColumnDirective = (rs) -> maxColumnWidth = 300 link = ($scope, $el, $attrs) -> + $scope.$on "sprint:zoom0", () => + recalculateTaskboardWidth() + $scope.$on "sprint:task:moved", () => recalculateTaskboardWidth() - bindOnce $scope, "usTasks", (project) -> - $scope.statusesFolded = rs.tasks.getStatusColumnModes($scope.project.id) - $scope.usFolded = rs.tasks.getUsRowModes($scope.project.id, $scope.sprintId) + $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() + recalculateTaskboardWidth() $scope.foldStatus = (status) -> $scope.statusesFolded[status.id] = !!!$scope.statusesFolded[status.id] @@ -403,7 +529,10 @@ TaskboardSquishColumnDirective = (rs) -> recalculateTaskboardWidth() getCeilWidth = (usId, statusId) => - tasks = $scope.usTasks[usId][statusId].length + 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] @@ -422,7 +551,10 @@ TaskboardSquishColumnDirective = (rs) -> if width column.css('max-width', width) else - column.css("max-width", maxColumnWidth) + if $scope.ctrl.zoomLevel == '0' + column.css("max-width", 148) + else + column.css("max-width", maxColumnWidth) refreshTaskboardTableWidth = () => columnWidths = [] @@ -458,67 +590,3 @@ TaskboardSquishColumnDirective = (rs) -> return {link: link} module.directive("tgTaskboardSquishColumn", ["$tgResources", TaskboardSquishColumnDirective]) - -############################################################################# -## Taskboard User Directive -############################################################################# - -TaskboardUserDirective = ($log, $translate, avatarService) -> - clickable = false - - link = ($scope, $el, $attrs) -> - username_label = $el.parent().find("a.task-assigned") - username_label.addClass("not-clickable") - - $scope.$watch 'task.assigned_to', (assigned_to) -> - user = $scope.usersById[assigned_to] - - avatar = avatarService.getAvatar(user) - - if user is undefined - _.assign($scope, { - name: $translate.instant("COMMON.ASSIGNED_TO.NOT_ASSIGNED"), - avatar: avatar, - clickable: clickable - }) - else - _.assign($scope, { - name: user.full_name_display, - avatar: avatar, - clickable: clickable - }) - - username_label.text($scope.name) - - - bindOnce $scope, "project", (project) -> - if project.my_permissions.indexOf("modify_task") > -1 - clickable = true - $el.find(".avatar-assigned-to").on "click", (event) => - if $el.find('a').hasClass('noclick') - return - - $ctrl = $el.controller() - $ctrl.editTaskAssignedTo($scope.task) - - username_label.removeClass("not-clickable") - username_label.on "click", (event) -> - if $el.find('a').hasClass('noclick') - return - - $ctrl = $el.controller() - $ctrl.editTaskAssignedTo($scope.task) - - - return { - link: link, - templateUrl: "taskboard/taskboard-user.html", - scope: { - "usersById": "=users", - "project": "=", - "task": "=", - } - } - - -module.directive("tgTaskboardUserAvatar", ["$log", "$translate", "tgAvatarService", TaskboardUserDirective]) diff --git a/app/coffee/modules/taskboard/sortable.coffee b/app/coffee/modules/taskboard/sortable.coffee index a5698cc8..b0c7bc4e 100644 --- a/app/coffee/modules/taskboard/sortable.coffee +++ b/app/coffee/modules/taskboard/sortable.coffee @@ -37,11 +37,14 @@ module = angular.module("taigaBacklog") ## Sortable Directive ############################################################################# -TaskboardSortableDirective = ($repo, $rs, $rootscope) -> +TaskboardSortableDirective = ($repo, $rs, $rootscope, $translate) -> link = ($scope, $el, $attrs) -> - bindOnce $scope, "tasks", (xx) -> - # If the user has not enough permissions we don't enable the sortable - if not ($scope.project.my_permissions.indexOf("modify_us") > -1) + unwatch = $scope.$watch "usTasks", (usTasks) -> + return if !usTasks || !usTasks.size + + unwatch() + + if not ($scope.project.my_permissions.indexOf("modify_task") > -1) return oldParentScope = null @@ -49,6 +52,10 @@ TaskboardSortableDirective = ($repo, $rs, $rootscope) -> itemEl = null tdom = $el + filterError = -> + text = $translate.instant("BACKLOG.SORTABLE_FILTER_ERROR") + $tgConfirm.notify("error", text) + deleteElement = (itemEl) -> # Completelly remove item and its scope from dom itemEl.scope().$destroy() @@ -63,12 +70,22 @@ TaskboardSortableDirective = ($repo, $rs, $rootscope) -> copy: false, mirrorContainer: $el[0], accepts: (el, target) -> return !$(target).hasClass('taskboard-userstory-box') - moves: (item) -> return $(item).hasClass('taskboard-task') + moves: (item) -> + return $(item).is('tg-card') }) drake.on 'drag', (item) -> oldParentScope = $(item).parent().scope() + if $el.hasClass("active-filters") + filterError() + + setTimeout (() -> + drake.cancel(true) + ), 0 + + return false + drake.on 'dragend', (item) -> parentEl = $(item).parent() itemEl = $(item) @@ -85,7 +102,7 @@ TaskboardSortableDirective = ($repo, $rs, $rootscope) -> deleteElement(itemEl) $scope.$apply -> - $rootscope.$broadcast("taskboard:task:move", itemTask, newUsId, newStatusId, itemIndex) + $rootscope.$broadcast("taskboard:task:move", itemTask, itemTask.getIn(['model', 'status']), newUsId, newStatusId, itemIndex) scroll = autoScroll([$('.taskboard-table-body')[0]], { @@ -107,5 +124,6 @@ module.directive("tgTaskboardSortable", [ "$tgRepo", "$tgResources", "$rootScope", + "$translate", TaskboardSortableDirective ]) diff --git a/app/coffee/modules/taskboard/taskboard-tasks.coffee b/app/coffee/modules/taskboard/taskboard-tasks.coffee new file mode 100644 index 00000000..cc8f087d --- /dev/null +++ b/app/coffee/modules/taskboard/taskboard-tasks.coffee @@ -0,0 +1,173 @@ +### +# Copyright (C) 2014-2016 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: home.service.coffee +### + +groupBy = @.taiga.groupBy + +class TaskboardTasksService extends taiga.Service + @.$inject = [] + constructor: () -> + @.reset() + + reset: () -> + @.tasksRaw = [] + @.foldStatusChanged = {} + @.usTasks = Immutable.Map() + + init: (project, usersById) -> + @.project = project + @.usersById = usersById + + resetFolds: () -> + @.foldStatusChanged = {} + @.refresh() + + toggleFold: (taskId) -> + @.foldStatusChanged[taskId] = !@.foldStatusChanged[taskId] + @.refresh() + + add: (task) -> + @.tasksRaw = @.tasksRaw.concat(task) + @.refresh() + + set: (tasks) -> + @.tasksRaw = tasks + @.refreshRawOrder() + @.refresh() + + setUserstories: (userstories) -> + @.userstories = userstories + + refreshRawOrder: () -> + @.order = {} + + @.order[task.id] = task.taskboard_order for task in @.tasksRaw + + assignOrders: (order) -> + order = _.invert(order) + @.order = _.assign(@.order, order) + + @.refresh() + + getTask: (id) -> + findedTask = null + + @.usTasks.forEach (us) -> + us.forEach (status) -> + findedTask = status.find (task) -> return task.get('id') == id + + return false if findedTask + + return false if findedTask + + return findedTask + + replace: (task) -> + @.usTasks = @.usTasks.map (us) -> + return us.map (status) -> + findedIndex = status.findIndex (usItem) -> + return usItem.get('id') == us.get('id') + + if findedIndex != -1 + status = status.set(findedIndex, task) + + return status + + getTaskModel: (id) -> + return _.find @.tasksRaw, (task) -> return task.id == id + + replaceModel: (task) -> + @.tasksRaw = _.map @.tasksRaw, (it) -> + if task.id == it.id + return task + else + return it + + @.refresh() + + move: (id, usId, statusId, index) -> + task = @.getTaskModel(id) + + taskByUsStatus = _.filter @.tasksRaw, (task) => + return task.status == statusId && task.user_story == usId + + taskByUsStatus = _.sortBy taskByUsStatus, (it) => @.order[it.id] + + taksWithoutMoved = _.filter taskByUsStatus, (it) => it.id != id + beforeDestination = _.slice(taksWithoutMoved, 0, index) + afterDestination = _.slice(taksWithoutMoved, index) + + setOrders = {} + + previous = beforeDestination[beforeDestination.length - 1] + + previousWithTheSameOrder = _.filter beforeDestination, (it) => + @.order[it.id] == @.order[previous.id] + + if previousWithTheSameOrder.length > 1 + for it in previousWithTheSameOrder + setOrders[it.id] = @.order[it.id] + + if !previous + @.order[task.id] = 0 + else if previous + @.order[task.id] = @.order[previous.id] + 1 + + for it, key in afterDestination + @.order[it.id] = @.order[task.id] + key + 1 + + task.status = statusId + task.user_story = usId + task.taskboard_order = @.order[task.id] + + @.refresh() + + return {"task_id": task.id, "order": @.order[task.id], "set_orders": setOrders} + + refresh: -> + @.tasksRaw = _.sortBy @.tasksRaw, (it) => @.order[it.id] + + tasks = @.tasksRaw + taskStatusList = _.sortBy(@.project.task_statuses, "order") + + usTasks = {} + + # Iterate over all userstories and + # null userstory for unassigned tasks + for us in _.union(@.userstories, [{id:null}]) + usTasks[us.id] = {} + for status in taskStatusList + usTasks[us.id][status.id] = [] + + for taskModel in tasks + if usTasks[taskModel.user_story]? and usTasks[taskModel.user_story][taskModel.status]? + task = {} + task.foldStatusChanged = @.foldStatusChanged[taskModel.id] + task.model = taskModel.getAttrs() + task.images = _.filter taskModel.attachments, (it) -> return !!it.thumbnail_card_url + task.id = taskModel.id + task.assigned_to = @.usersById[taskModel.assigned_to] + task.colorized_tags = _.map task.model.tags, (tag) => + color = @.project.tags_colors[tag] + return {name: tag, color: color} + + usTasks[taskModel.user_story][taskModel.status].push(task) + + @.usTasks = Immutable.fromJS(usTasks) + +angular.module("taigaKanban").service("tgTaskboardTasks", TaskboardTasksService) diff --git a/app/coffee/utils.coffee b/app/coffee/utils.coffee index fc6d1757..d69a91c3 100644 --- a/app/coffee/utils.coffee +++ b/app/coffee/utils.coffee @@ -38,7 +38,7 @@ bindMethods = (object) => methods = [] _.forIn object, (value, key) => - if key not in dependencies + if key not in dependencies && _.isFunction(value) methods.push(key) _.bindAll(object, methods) diff --git a/app/locales/taiga/locale-en.json b/app/locales/taiga/locale-en.json index 41abba09..dd3166f4 100644 --- a/app/locales/taiga/locale-en.json +++ b/app/locales/taiga/locale-en.json @@ -45,6 +45,10 @@ "CAPSLOCK_WARNING": "Be careful! You are using capital letters in an input field that is case sensitive.", "CONFIRM_CLOSE_EDIT_MODE_TITLE": "Are you sure you want to close the edit mode?", "CONFIRM_CLOSE_EDIT_MODE_MESSAGE": "Remember that if you close the edit mode without saving all the changes will be lost", + "CARD": { + "ASSIGN_TO": "Assign To", + "EDIT": "Edit card" + }, "FORM_ERRORS": { "DEFAULT_MESSAGE": "This value seems to be invalid.", "TYPE_EMAIL": "This value should be a valid email.", @@ -196,9 +200,25 @@ "TITLE": "filters", "INPUT_PLACEHOLDER": "Subject or reference", "TITLE_ACTION_FILTER_BUTTON": "search", - "BREADCRUMB_TITLE": "back to categories", - "BREADCRUMB_FILTERS": "Filters", - "BREADCRUMB_STATUS": "status" + "TITLE": "Filters", + "INPUT_SEARCH_PLACEHOLDER": "Subject or ref", + "TITLE_ACTION_SEARCH": "Search", + "ACTION_SAVE_CUSTOM_FILTER": "save as custom filter", + "PLACEHOLDER_FILTER_NAME": "Write the filter name and press enter", + "CATEGORIES": { + "TYPE": "Type", + "STATUS": "Status", + "SEVERITY": "Severity", + "PRIORITIES": "Priorities", + "TAGS": "Tags", + "ASSIGNED_TO": "Assigned to", + "CREATED_BY": "Created by", + "CUSTOM_FILTERS": "Custom filters" + }, + "CONFIRM_DELETE": { + "TITLE": "Delete custom filter", + "MESSAGE": "the custom filter '{{customFilterName}}'" + } }, "WYSIWYG": { "H1_BUTTON": "First Level Heading", @@ -1169,9 +1189,7 @@ "TITLE": "Filters", "REMOVE": "Remove Filters", "HIDE": "Hide Filters", - "SHOW": "Show Filters", - "FILTER_CATEGORY_STATUS": "Status", - "FILTER_CATEGORY_TAGS": "Tags" + "SHOW": "Show Filters" }, "SPRINTS": { "TITLE": "SPRINTS", @@ -1278,7 +1296,6 @@ "SECTION_NAME": "Issue", "ACTION_NEW_ISSUE": "+ NEW ISSUE", "ACTION_PROMOTE_TO_US": "Promote to User Story", - "PLACEHOLDER_FILTER_NAME": "Write the filter name and press enter", "PROMOTED": "This issue has been promoted to US:", "EXTERNAL_REFERENCE": "This issue has been created from", "GO_TO_EXTERNAL_REFERENCE": "Go to origin", @@ -1296,28 +1313,6 @@ "TITLE": "Promote this issue to a new user story", "MESSAGE": "Are you sure you want to create a new US from this Issue?" }, - "FILTERS": { - "TITLE": "Filters", - "INPUT_SEARCH_PLACEHOLDER": "Subject or ref", - "TITLE_ACTION_SEARCH": "Search", - "ACTION_SAVE_CUSTOM_FILTER": "save as custom filter", - "BREADCRUMB": "Filters", - "TITLE_BREADCRUMB": "Filters", - "CATEGORIES": { - "TYPE": "Type", - "STATUS": "Status", - "SEVERITY": "Severity", - "PRIORITIES": "Priorities", - "TAGS": "Tags", - "ASSIGNED_TO": "Assigned to", - "CREATED_BY": "Created by", - "CUSTOM_FILTERS": "Custom filters" - }, - "CONFIRM_DELETE": { - "TITLE": "Delete custom filter", - "MESSAGE": "the custom filter '{{customFilterName}}'" - } - }, "TABLE": { "COLUMNS": { "TYPE": "Type", diff --git a/app/modules/components/board-zoom/board-zoom.directive.coffee b/app/modules/components/board-zoom/board-zoom.directive.coffee new file mode 100644 index 00000000..cba9fdcf --- /dev/null +++ b/app/modules/components/board-zoom/board-zoom.directive.coffee @@ -0,0 +1,29 @@ +### +# Copyright (C) 2014-2016 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: board-zoom.directive.coffee +### + +BoardZoomDirective = () -> + return { + scope: { + levels: "=", + value: "=" + }, + templateUrl: 'components/board-zoom/board-zoom.html' + } + +angular.module('taigaComponents').directive("tgBoardZoom", [BoardZoomDirective]) diff --git a/app/modules/components/board-zoom/board-zoom.jade b/app/modules/components/board-zoom/board-zoom.jade new file mode 100644 index 00000000..e6067dbb --- /dev/null +++ b/app/modules/components/board-zoom/board-zoom.jade @@ -0,0 +1,9 @@ +input.range-slider( + type="range", + min="0", + max="{{levels - 1}}", + step="1" + ng-model="value" + ng-model-options="{ debounce: 200 }" + tg-bind-scope +) diff --git a/app/modules/components/board-zoom/board-zoom.scss b/app/modules/components/board-zoom/board-zoom.scss new file mode 100644 index 00000000..5e5d7eb2 --- /dev/null +++ b/app/modules/components/board-zoom/board-zoom.scss @@ -0,0 +1,108 @@ +$track-color: $whitish; +$thumb-color: $grayer; +$thumb-shadow: rgba($thumb-color, .3); + +$thumb-radius: 50%; +$thumb-height: 14px; +$thumb-width: 14px; +$thumb-border-width: 0; +$thumb-border-color: transparent; + +$track-width: 200px; +$track-height: 3px; +$track-border-width: 0; +$track-border-color: transparent; + +$track-radius: 1px; +$contrast: 2; + +@mixin track() { + width: $track-width; + height: $track-height; + cursor: pointer; + transition: all .2s ease; +} + +@mixin thumb() { + border: $thumb-border-width solid $thumb-border-color; + height: $thumb-height; + width: $thumb-width; + border-radius: $thumb-radius; + background: $thumb-color; + cursor: pointer; + box-shadow: 0 0 0 2px $thumb-shadow; + transition: box-shadow .2s; +} + +.range-slider { + -webkit-appearance: none; + margin: $thumb-height / 2 0; + width: $track-width; + + &:focus { + &::-webkit-slider-runnable-track { + background: lighten($track-color, $contrast); + } + &::-webkit-slider-thumb { + box-shadow: 0 0 0 4px $thumb-shadow; + } + &::-moz-range-thumb { + box-shadow: 0 0 0 4px $thumb-shadow; + } + &::-ms-fill-lower { + background: $track-color; + } + &::-ms-fill-upper { + background: lighten($track-color, $contrast); + } + } + + &::-webkit-slider-runnable-track { + @include track(); + background: $track-color; + border: $track-border-width solid $track-border-color; + border-radius: $track-radius; + } + + &::-webkit-slider-thumb { + @include thumb(); + -webkit-appearance: none; + margin-top: ((-$track-border-width * 2 + $track-height) / 2) - ($thumb-height / 2); + } + + &::-moz-range-track { + @include track(); + background: $track-color; + border: $track-border-width solid $track-border-color; + border-radius: $track-radius; + } + + &::-moz-range-thumb { + @include thumb(); + } + + &::-ms-track { + @include track(); + background: transparent; + border-color: transparent; + border-width: $thumb-width 0; + color: transparent; + } + + &::-ms-fill-lower { + background: darken($track-color, $contrast); + border: $track-border-width solid $track-border-color; + border-radius: $track-radius * 2; + } + + &::-ms-fill-upper { + background: $track-color; + border: $track-border-width solid $track-border-color; + border-radius: $track-radius * 2; + } + + &::-ms-thumb { + @include thumb(); + } + +} diff --git a/app/modules/components/card-slideshow/card-slideshow.controller.coffee b/app/modules/components/card-slideshow/card-slideshow.controller.coffee new file mode 100644 index 00000000..552f65bb --- /dev/null +++ b/app/modules/components/card-slideshow/card-slideshow.controller.coffee @@ -0,0 +1,38 @@ +### +# Copyright (C) 2014-2015 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: card-slideshow.controller.coffee +### + +class CardSlideshowController + @.$inject = [] + + constructor: () -> + @.index = 0 + + next: () -> + @.index++ + + if @.index >= @.images.size + @.index = 0 + + previous: () -> + @.index-- + + if @.index < 0 + @.index = @.images.size - 1 + +angular.module('taigaComponents').controller('CardSlideshow', CardSlideshowController) diff --git a/app/modules/components/card-slideshow/card-slideshow.directive.coffee b/app/modules/components/card-slideshow/card-slideshow.directive.coffee new file mode 100644 index 00000000..bbce104b --- /dev/null +++ b/app/modules/components/card-slideshow/card-slideshow.directive.coffee @@ -0,0 +1,33 @@ +### +# Copyright (C) 2014-2015 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: card.directive.coffee +### + +module = angular.module("taigaComponents") + +cardSlideshowDirective = () -> + return { + controller: "CardSlideshow", + templateUrl: "components/card-slideshow/card-slideshow.html", + bindToController: true, + controllerAs: "vm", + scope: { + images: "=" + } + } + +module.directive('tgCardSlideshow', cardSlideshowDirective) diff --git a/app/modules/components/card-slideshow/card-slideshow.jade b/app/modules/components/card-slideshow/card-slideshow.jade new file mode 100644 index 00000000..0f42c061 --- /dev/null +++ b/app/modules/components/card-slideshow/card-slideshow.jade @@ -0,0 +1,18 @@ +.card-slideshow(ng-if="vm.images.size") + tg-svg.slideshow-icon.slideshow-left( + ng-click="vm.previous()" + ng-if="vm.images.size > 1" + svg-icon="icon-arrow-left" + ) + tg-svg.slideshow-icon.slideshow-right( + ng-click="vm.next()" + ng-if="vm.images.size > 1" + svg-icon="icon-arrow-right" + ) + + .card-slideshow-wrapper( + ng-if="$index == vm.index" + tg-repeat="image in vm.images track by image.get('id')" + ) + tg-preload-image(preload-src="{{image.get('thumbnail_card_url')}}") + img(ng-src="{{image.get('thumbnail_card_url')}}") diff --git a/app/modules/components/card/card-templates/card-completion.jade b/app/modules/components/card/card-templates/card-completion.jade new file mode 100644 index 00000000..1f2fa662 --- /dev/null +++ b/app/modules/components/card/card-templates/card-completion.jade @@ -0,0 +1,4 @@ +.card-completion(ng-if="vm.visible('extra_info') && vm.item.getIn(['model', 'tasks']).size") + .card-completion-bar + .card-completion-percentage(ng-style="{width: vm.closedTasksPercent() + '%'}" ) + span.card-tooltip tasks {{vm.getClosedTasks().size}}/{{vm.item.getIn(['model', 'tasks']).size}} diff --git a/app/modules/components/card/card-templates/card-data.jade b/app/modules/components/card/card-templates/card-data.jade new file mode 100644 index 00000000..e918b9fc --- /dev/null +++ b/app/modules/components/card/card-templates/card-data.jade @@ -0,0 +1,21 @@ +.card-data( + ng-if="vm.visible('extra_info')" + ng-class="{'empty-tasks': !vm.item.getIn(['model', 'tasks']).size}" +) + span.card-estimation( + ng-if="vm.item.getIn(['model', 'total_points']) === null", + translate="US.NOT_ESTIMATED" + ) + span.card-estimation( + ng-if="vm.item.getIn(['model', 'total_points'])" + ) {{"COMMON.FIELDS.POINTS" | translate}} {{vm.item.getIn(['model', 'total_points'])}} + .card-statistics + .statistic.card-votes(ng-class="{'active': vm.item.getIn(['model', 'is_voter'])}") + tg-svg(svg-icon="icon-upvote") + span {{vm.item.getIn(['model', 'total_voters'])}} + .statistic.card-watchers + tg-svg(svg-icon="icon-watch") + span {{vm.item.getIn(['model', 'watchers']).size}} + .statistic.card-attachments(ng-if="vm.item.getIn(['model', 'attachments']).size") + tg-svg(svg-icon="icon-attachment") + span {{vm.item.getIn(['model', 'attachments']).size}} diff --git a/app/modules/components/card/card-templates/card-owner.jade b/app/modules/components/card/card-templates/card-owner.jade new file mode 100644 index 00000000..682e0c4a --- /dev/null +++ b/app/modules/components/card/card-templates/card-owner.jade @@ -0,0 +1,43 @@ +.card-owner + .card-owner-info(ng-if="vm.item.get('assigned_to')") + .card-owner-avatar + img( + ng-class="{'is-iocaine': vm.item.getIn(['model', 'is_iocaine'])}" + tg-avatar="vm.item.get('assigned_to')" + ) + tg-svg( + ng-if="vm.item.getIn(['model', 'is_iocaine'])" + svg-icon="icon-iocaine" + svg-title="COMMON.IOCAINE_TEXT" + ) + span.card-owner-name(ng-if="vm.visible('owner')") {{vm.item.getIn(['assigned_to', 'full_name'])}} + div(ng-if="!vm.visible('owner')") + include card-title + + .card-owner-info(ng-if="!vm.item.get('assigned_to')") + img(ng-src="/#{v}/images/unnamed.png") + span.card-owner-name( + ng-if="vm.visible('owner')", + translate="COMMON.ASSIGNED_TO.NOT_ASSIGNED" + ) + div(ng-if="!vm.visible('owner')") + include card-title + + .card-owner-actions( + ng-if="vm.visible('owner')" + tg-check-permission="{{vm.getPermissionsKey()}}" + ) + a.e2e-assign.card-owner-assign( + ng-click="vm.onClickAssignedTo({id: vm.item.get('id')})" + href="" + ) + tg-svg(svg-icon="icon-add-user") + span(translate="COMMON.CARD.ASSIGN_TO") + + a.e2e-edit.card-edit( + href="" + ng-click="vm.onClickEdit({id: vm.item.get('id')})" + tg-loading="vm.item.get('loading')" + ) + tg-svg(svg-icon="icon-edit") + span(translate="COMMON.CARD.EDIT") diff --git a/app/modules/components/card/card-templates/card-tags.jade b/app/modules/components/card/card-templates/card-tags.jade new file mode 100644 index 00000000..a5161331 --- /dev/null +++ b/app/modules/components/card/card-templates/card-tags.jade @@ -0,0 +1,7 @@ +.card-tags(ng-if="vm.visible('tags')") + span.card-tag( + tg-repeat="tag in vm.item.get('colorized_tags') track by tag.get('name')" + style="background-color: {{tag.get('color')}}" + title="{{tag.get('name')}}" + ng-if="tag.get('color')" + ) diff --git a/app/modules/components/card/card-templates/card-tasks.jade b/app/modules/components/card/card-templates/card-tasks.jade new file mode 100644 index 00000000..d029bc1e --- /dev/null +++ b/app/modules/components/card/card-templates/card-tasks.jade @@ -0,0 +1,7 @@ +ul.card-tasks(ng-if="vm.isRelatedTasksVisible()") + li.card-task(tg-repeat="task in vm.item.getIn(['model', 'tasks'])") + a( + href="#" + tg-nav="project-tasks-detail:project=vm.project.slug,ref=task.get('ref')", + ng-class="{'closed-task': task.get('is_closed'), 'blocked-task': task.get('is_blocked')}" + ) {{"#" + task.get('ref')}} {{task.get('subject')}} diff --git a/app/modules/components/card/card-templates/card-title.jade b/app/modules/components/card/card-templates/card-title.jade new file mode 100644 index 00000000..ad5434d8 --- /dev/null +++ b/app/modules/components/card/card-templates/card-title.jade @@ -0,0 +1,9 @@ +h2.card-title + a( + href="" + tg-nav="{{vm.getNavKey()}}:project=vm.project.slug,ref=vm.item.getIn(['model', 'ref'])", + tg-nav-get-params="{\"kanban-status\": {{vm.item.getIn(['model', 'status'])}}}" + title="#{{ ::vm.item.getIn(['model', 'ref']) }} {{ vm.item.getIn(['model', 'subject'])}}" + ) + span(ng-if="vm.visible('ref')") {{::"#" + vm.item.getIn(['model', 'ref'])}} + span.e2e-title(ng-if="vm.visible('subject')") {{vm.item.getIn(['model', 'subject'])}} diff --git a/app/modules/components/card/card-templates/card-unfold.jade b/app/modules/components/card/card-templates/card-unfold.jade new file mode 100644 index 00000000..5b734660 --- /dev/null +++ b/app/modules/components/card/card-templates/card-unfold.jade @@ -0,0 +1,6 @@ +.card-unfold.ng-animate-disabled( + ng-click="vm.toggleFold()" + ng-if="vm.visible('unfold') && (vm.item.getIn(['model', 'tasks']).size || vm.item.get('images').size)" + role="button" +) + tg-svg(svg-icon="icon-view-more") diff --git a/app/modules/components/card/card.controller.coffee b/app/modules/components/card/card.controller.coffee new file mode 100644 index 00000000..25dce94c --- /dev/null +++ b/app/modules/components/card/card.controller.coffee @@ -0,0 +1,82 @@ +### +# Copyright (C) 2014-2015 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: card.controller.coffee +### + +class CardController + @.$inject = [] + + visible: (name) -> + return @.zoom.indexOf(name) != -1 + + toggleFold: () -> + @.onToggleFold({id: @.item.get('id')}) + + getClosedTasks: () -> + return @.item.getIn(['model', 'tasks']).filter (task) -> return task.get('is_closed'); + + closedTasksPercent: () -> + return @.getClosedTasks().size * 100 / @.item.getIn(['model', 'tasks']).size + + getPermissionsKey: () -> + if @.type == 'task' + return 'modify_task' + else + return 'modify_us' + + _setVisibility: () -> + visibility = { + related: @.visible('related_tasks'), + slides: @.visible('attachments') + } + + if!_.isUndefined(@.item.get('foldStatusChanged')) + if @.visible('related_tasks') && @.visible('attachments') + visibility.related = !@.item.get('foldStatusChanged') + visibility.slides = !@.item.get('foldStatusChanged') + else if @.visible('attachments') + visibility.related = @.item.get('foldStatusChanged') + visibility.slides = @.item.get('foldStatusChanged') + else if !@.visible('related_tasks') && !@.visible('attachments') + visibility.related = @.item.get('foldStatusChanged') + visibility.slides = @.item.get('foldStatusChanged') + + if !@.item.getIn(['model', 'tasks']) || !@.item.getIn(['model', 'tasks']).size + visibility.related = false + + if !@.item.get('images') || !@.item.get('images').size + visibility.slides = false + + return visibility + + isRelatedTasksVisible: () -> + visibility = @._setVisibility() + + return visibility.related + + isSlideshowVisible: () -> + visibility = @._setVisibility() + + return visibility.slides + + getNavKey: () -> + if @.type == 'task' + return 'project-tasks-detail' + else + return 'project-userstories-detail' + +angular.module('taigaComponents').controller('Card', CardController) diff --git a/app/modules/components/card/card.controller.spec.coffee b/app/modules/components/card/card.controller.spec.coffee new file mode 100644 index 00000000..3e2ed30c --- /dev/null +++ b/app/modules/components/card/card.controller.spec.coffee @@ -0,0 +1,142 @@ +### +# Copyright (C) 2014-2016 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: card.controller.spec.coffee +### + +describe "Card", -> + $provide = null + $controller = null + mocks = {} + + _inject = -> + inject (_$controller_) -> + $controller = _$controller_ + + _setup = -> + _inject() + + beforeEach -> + module "taigaComponents" + + _setup() + + it "toggle fold callback", () -> + ctrl = $controller("Card") + + ctrl.item = Immutable.fromJS({id: 2}) + ctrl.onToggleFold = sinon.spy() + + ctrl.toggleFold() + + expect(ctrl.onToggleFold).to.have.been.calledWith({id: 2}) + + it "get closed tasks", () -> + ctrl = $controller("Card") + + ctrl.item = Immutable.fromJS({ + id: 2, + model: { + tasks: [ + {is_closed: true}, + {is_closed: false}, + {is_closed: true} + ] + } + }) + + tasks = ctrl.getClosedTasks() + expect(tasks.size).to.be.equal(2) + + it "get closed percent", () -> + ctrl = $controller("Card") + + ctrl.item = Immutable.fromJS({ + id: 2, + model: { + tasks: [ + {is_closed: true}, + {is_closed: false}, + {is_closed: false}, + {is_closed: true} + ] + } + }) + + percent = ctrl.closedTasksPercent() + expect(percent).to.be.equal(50) + + describe "check if related task and slides visibility", () -> + it "no content", () -> + ctrl = $controller("Card") + + ctrl.item = Immutable.fromJS({ + id: 2, + images: [], + model: { + tasks: [] + } + }) + + ctrl.visible = () => return true + + visibility = ctrl._setVisibility() + + expect(visibility).to.be.eql({ + related: false, + slides: false + }) + + it "with content", () -> + ctrl = $controller("Card") + + ctrl.item = Immutable.fromJS({ + id: 2, + images: [3,4], + model: { + tasks: [1,2] + } + }) + + ctrl.visible = () => return true + + visibility = ctrl._setVisibility() + + expect(visibility).to.be.eql({ + related: true, + slides: true + }) + + it "fold", () -> + ctrl = $controller("Card") + + ctrl.item = Immutable.fromJS({ + foldStatusChanged: true, + id: 2, + images: [3,4], + model: { + tasks: [1,2] + } + }) + + ctrl.visible = () => return true + + visibility = ctrl._setVisibility() + + expect(visibility).to.be.eql({ + related: false, + slides: false + }) diff --git a/app/modules/components/card/card.directive.coffee b/app/modules/components/card/card.directive.coffee new file mode 100644 index 00000000..4ad69505 --- /dev/null +++ b/app/modules/components/card/card.directive.coffee @@ -0,0 +1,43 @@ +### +# Copyright (C) 2014-2015 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: card.directive.coffee +### + +module = angular.module("taigaComponents") + +cardDirective = () -> + return { + link: (scope) -> + + controller: "Card", + controllerAs: "vm", + bindToController: true, + templateUrl: "components/card/card.html", + scope: { + onToggleFold: "&", + onClickAssignedTo: "&", + onClickEdit: "&", + project: "=", + item: "=", + zoom: "=", + zoomLevel: "=", + archived: "=", + type: "@" + } + } + +module.directive('tgCard', cardDirective) diff --git a/app/modules/components/card/card.jade b/app/modules/components/card/card.jade new file mode 100644 index 00000000..556c3c82 --- /dev/null +++ b/app/modules/components/card/card.jade @@ -0,0 +1,16 @@ +.card-inner( + class="{{'zoom-' + vm.zoomLevel}}" + ng-class="{'card-blocked': vm.item.getIn(['model', 'is_blocked']), 'archived': vm.archived}" +) + include card-templates/card-tags + include card-templates/card-owner + div(ng-if="vm.visible('owner')") + include card-templates/card-title + include card-templates/card-data + include card-templates/card-completion + include card-templates/card-tasks + tg-card-slideshow( + ng-if="vm.isSlideshowVisible()" + images="vm.item.get('images')" + ) + include card-templates/card-unfold diff --git a/app/modules/components/card/card.scss b/app/modules/components/card/card.scss new file mode 100644 index 00000000..b31f11a3 --- /dev/null +++ b/app/modules/components/card/card.scss @@ -0,0 +1,326 @@ +.card { + box-shadow: 2px 2px 4px darken($whitish, 10%); + cursor: move; + display: block; + margin: 0 .6rem .6rem; + overflow: hidden; + transition: box-shadow .2s ease-in; + &:hover { + box-shadow: 3px 3px 6px darken($whitish, 10%); + } +} + +.card-inner { + background: $white; + border-radius: .25rem; + &.zoom-0, + &.zoom-1 { + .card-title { + flex: 1; + margin: 0; + padding: .25rem; + } + } + &.zoom-1 { + .card-owner-info { + align-items: flex-start; + } + } + &.card-blocked { + background: $red-light; + .statistic, + .card-title a, + .card-owner-name, + .card-estimation { + color: $white; + } + .card-owner-actions { + background: rgba($red-light, .9); + } + svg { + fill: $white; + } + .statistic { + &.active { + color: $white; + } + } + .card-unfold { + &:hover { + background: rgba($red-light, .9); + } + } + &.zoom-0, + &.zoom-1 { + .card-title { + color: $white; + } + } + } +} + +.card-tags { + display: flex; + .card-tag { + display: block; + flex: 1; + height: .5rem; + + } +} + +.card-owner { + position: relative; + .card-owner-info { + align-items: center; + display: flex; + } + .card-owner-avatar { + line-height: 0; + position: relative; + } + .icon-iocaine { + @include svg-size(1.2rem); + background: rgba($blackish, .8); + border-radius: 4px 0 0; + bottom: .25rem; + fill: $whitish; + padding: .25rem; + position: absolute; + right: .5rem; + } + .is-iocaine { + filter: hue-rotate(265deg) saturate(3); + } + &:hover { + .card-owner-actions { + opacity: 1; + } + } + img { + flex-shrink: 0; + height: 2.5rem; + margin-right: .5rem; + width: 2.5rem; + } + .card-owner-name { + color: $gray-light; + } +} + +.card-owner-actions { + background: rgba($white, .9); + display: flex; + justify-content: space-between; + left: 0; + opacity: 0; + position: absolute; + top: 0; + transition: all .2s; + width: 100%; + &:hover { + color: $primary-light; + svg { + fill: currentColor; + } + } + .icon { + @include svg-size(1.2rem); + display: inline-block; + margin-right: .25rem; + padding: 0; + } + a { + align-items: center; + cursor: pointer; + display: flex; + padding: .6rem 1rem; + } +} + +.card-title { + @include font-size(normal); + line-height: 1.25; + margin-bottom: .25rem; + padding: 1rem 1rem 0; + span { + padding-right: .25rem; + } +} + +.card-data { + color: $gray-light; + display: flex; + font-size: 14px; + justify-content: space-between; + padding: 0 1rem .5rem; +} + +.card-statistics { + @include font-size(small); + color: lighten($gray-light, 25%); + display: flex; + margin-left: auto; + .statistic { + align-content: center; + display: flex; + margin-left: .75rem; + &.active { + color: $primary-light; + svg { + fill: currentColor; + } + } + } + .icon { + @include svg-size(.75rem); + fill: lighten($gray-light, 25%); + margin-right: .2rem; + } +} + +.card-completion { + margin: 0 1rem .5rem; + position: relative; + .card-completion-bar { + background: $whitish; + height: .4rem; + width: 100%; + } + .card-completion-percentage { + background: $primary-light; + cursor: pointer; + height: .4rem; + left: 0; + position: absolute; + top: 0; + &:hover { + + .card-tooltip { + opacity: 1; + } + } + } + .card-tooltip { + background: $blackish; + border-radius: 5px; + color: $white; + font-size: 14px; + left: calc(25% - 50px); + opacity: 0; + padding: .25rem 1rem; + position: absolute; + text-align: center; + top: -2.25rem; + transition: opacity .2s; + width: 100px; + &::after { + background: $black; + content: ''; + height: 10px; + left: 50%; + position: absolute; + top: 70%; + transform: rotate(45deg); + width: 10px; + } + } +} + +.card-unfold { + align-items: center; + cursor: pointer; + display: flex; + justify-content: center; + margin: 0; + padding: .25rem; + &:hover { + background: linear-gradient(to bottom, $white, darken($white, 1%)); + } + svg { + @include svg-size($width: 2rem, $height: .3rem); + fill: $whitish; + } +} + +.card-tasks { + border-top: 1px solid $whitish; + margin: 0; + margin-top: .5rem; + padding: 0; +} + +.card-task { + @include font-size(xsmall); + border-bottom: 1px solid $whitish; + list-style: none; + a { + color: $gray-light; + display: block; + overflow: hidden; + padding: .5rem .75rem; + text-overflow: ellipsis; + transition: color .2s; + white-space: nowrap; + &.blocked-task { + color: $red-light; + } + &.closed-task { + color: $gray-light; + text-decoration: line-through; + } + &:hover { + color: $primary; + } + } +} + +.card-slideshow { + position: relative; + &:hover { + .slideshow-left, + .slideshow-right { + background: rgba($white, .2); + padding: .25rem; + transition: background .2s; + } + } + .slideshow-icon { + cursor: pointer; + position: absolute; + top: 35%; + &:hover { + background: rgba($primary-light, .5); + transition: background .2s; + } + } + svg { + @include svg-size(1.2rem); + transition: fill .2s; + } + .slideshow-left, + .slideshow-right { + background: transparent; + padding: .25rem; + } + .slideshow-left { + left: 0; + } + .slideshow-right { + right: 0; + } + img { + width: 100%; + } +} + +.card-slideshow-wrapper { + align-items: center; + display: flex; + height: 120px; + justify-content: center; + overflow: hidden; + .loading-spinner { + min-height: 3rem; + min-width: 3rem; + } +} diff --git a/app/modules/components/filter/filter-remote.service.coffee b/app/modules/components/filter/filter-remote.service.coffee new file mode 100644 index 00000000..95435432 --- /dev/null +++ b/app/modules/components/filter/filter-remote.service.coffee @@ -0,0 +1,68 @@ +### +# Copyright (C) 2014-2016 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: filter-utils.service.coffee +### + +generateHash = taiga.generateHash + +class FilterRemoteStorageService extends taiga.Service + @.$inject = [ + "$q", + "$tgUrls", + "$tgHttp" + ] + + constructor: (@q, @urls, @http) -> + + storeFilters: (projectId, myFilters, filtersHashSuffix) -> + deferred = @q.defer() + url = @urls.resolve("user-storage") + ns = "#{projectId}:#{filtersHashSuffix}" + hash = generateHash([projectId, ns]) + if _.isEmpty(myFilters) + promise = @http.delete("#{url}/#{hash}", {key: hash, value:myFilters}) + promise.then -> + deferred.resolve() + promise.then null, -> + deferred.reject() + else + promise = @http.put("#{url}/#{hash}", {key: hash, value:myFilters}) + promise.then (data) -> + deferred.resolve() + promise.then null, (data) => + innerPromise = @http.post("#{url}", {key: hash, value:myFilters}) + innerPromise.then -> + deferred.resolve() + innerPromise.then null, -> + deferred.reject() + return deferred.promise + + getFilters: (projectId, filtersHashSuffix) -> + deferred = @q.defer() + url = @urls.resolve("user-storage") + ns = "#{projectId}:#{filtersHashSuffix}" + hash = generateHash([projectId, ns]) + + promise = @http.get("#{url}/#{hash}") + promise.then (data) -> + deferred.resolve(data.data.value) + promise.then null, (data) -> + deferred.resolve({}) + + return deferred.promise + +angular.module("taigaComponents").service("tgFilterRemoteStorageService", FilterRemoteStorageService) diff --git a/app/modules/components/filter/filter-slide-down.directive.coffee b/app/modules/components/filter/filter-slide-down.directive.coffee new file mode 100644 index 00000000..d0f4dbaf --- /dev/null +++ b/app/modules/components/filter/filter-slide-down.directive.coffee @@ -0,0 +1,45 @@ +### +# Copyright (C) 2014-2015 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: filter.-slide-down.controller.coffee +### + +FilterSlideDownDirective = () -> + link = (scope, el, attrs, ctrl) -> + filter = $('tg-filter') + + scope.$watch attrs.ngIf, (value) -> + if value + filter.find('.filter-list').hide() + + wrapperHeight = filter.height() + contentHeight = 0 + + filter.children().each () -> + contentHeight += $(this).outerHeight(true) + + $(el.context.nextSibling) + .css({ + "max-height": wrapperHeight - contentHeight, + "display": "block" + }) + + return { + priority: 900, + link: link + } + +angular.module('taigaComponents').directive("tgFilterSlideDown", [FilterSlideDownDirective]) diff --git a/app/modules/components/filter/filter.controller.coffee b/app/modules/components/filter/filter.controller.coffee new file mode 100644 index 00000000..0830f63d --- /dev/null +++ b/app/modules/components/filter/filter.controller.coffee @@ -0,0 +1,70 @@ +### +# Copyright (C) 2014-2015 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: filter.controller.coffee +### + +class FilterController + @.$inject = [] + + constructor: () -> + @.opened = null + @.customFilterForm = false + @.customFilterName = '' + + toggleFilterCategory: (filterName) -> + if @.opened == filterName + @.opened = null + else + @.opened = filterName + + isOpen: (filterName) -> + return @.opened == filterName + + saveCustomFilter: () -> + @.onSaveCustomFilter({name: @.customFilterName}) + @.customFilterForm = false + @.opened = 'custom-filter' + @.customFilterName = '' + + changeQ: () -> + @.onChangeQ({q: @.q}) + + unselectFilter: (filter) -> + @.onRemoveFilter({filter: filter}) + + unselectFilter: (filter) -> + @.onRemoveFilter({filter: filter}) + + selectFilter: (filterCategory, filter) -> + filter = { + category: filterCategory + filter: filter + } + + @.onAddFilter({filter: filter}) + + removeCustomFilter: (filter) -> + @.onRemoveCustomFilter({filter: filter}) + + selectCustomFilter: (filter) -> + @.onSelectCustomFilter({filter: filter}) + + isFilterSelected: (filterCategory, filter) -> + return !!_.find @.selectedFilters, (it) -> + return filter.id == it.id && filterCategory.dataType == it.dataType + +angular.module('taigaComponents').controller('Filter', FilterController) diff --git a/app/modules/components/filter/filter.controller.spec.coffee b/app/modules/components/filter/filter.controller.spec.coffee new file mode 100644 index 00000000..c7166a4b --- /dev/null +++ b/app/modules/components/filter/filter.controller.spec.coffee @@ -0,0 +1,87 @@ +### +# Copyright (C) 2014-2016 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: filter.controller.spec.coffee +### + +describe "Filter", -> + $provide = null + $controller = null + mocks = {} + + _inject = -> + inject (_$controller_) -> + $controller = _$controller_ + + _setup = -> + _inject() + + beforeEach -> + module "taigaComponents" + + _setup() + + it "toggle filter category", () -> + ctrl = $controller("Filter") + + ctrl.toggleFilterCategory('filter1') + + expect(ctrl.opened).to.be.equal('filter1') + + ctrl.toggleFilterCategory('filter1') + + expect(ctrl.opened).to.be.null + + it "is filter open", () -> + ctrl = $controller("Filter") + ctrl.opened = 'filter1' + + isOpen = ctrl.isOpen('filter1') + + expect(isOpen).to.be.true; + + it "save custom filter", () -> + ctrl = $controller("Filter") + ctrl.customFilterName = "custom-name" + ctrl.customFilterForm = true + ctrl.onSaveCustomFilter = sinon.spy() + + ctrl.saveCustomFilter() + + expect(ctrl.onSaveCustomFilter).to.have.been.calledWith({name: "custom-name"}) + expect(ctrl.customFilterForm).to.be.false + expect(ctrl.opened).to.be.equal('custom-filter') + expect(ctrl.customFilterName).to.be.equal('') + + it "is filter selected", () -> + ctrl = $controller("Filter") + ctrl.selectedFilters = [ + {id: 1, dataType: "1"}, + {id: 2, dataType: "2"}, + {id: 3, dataType: "3"} + ] + + filterCategory = {dataType: "x"} + filter = {id: 1} + isFilterSelected = ctrl.isFilterSelected(filterCategory, filter) + + expect(isFilterSelected).to.be.false + + filterCategory = {dataType: "1"} + filter = {id: 1} + isFilterSelected = ctrl.isFilterSelected(filterCategory, filter) + + expect(isFilterSelected).to.be.true diff --git a/app/modules/components/filter/filter.directive.coffee b/app/modules/components/filter/filter.directive.coffee new file mode 100644 index 00000000..92dca69f --- /dev/null +++ b/app/modules/components/filter/filter.directive.coffee @@ -0,0 +1,44 @@ +### +# Copyright (C) 2014-2016 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: filter.directive.coffee +### + +FilterDirective = () -> + link = (scope, el, attrs, ctrl) -> + + return { + scope: { + onChangeQ: "&", + onAddFilter: "&", + onSelectCustomFilter: "&", + onRemoveFilter: "&", + onRemoveCustomFilter: "&", + onSaveCustomFilter: "&", + customFilters: "<", + q: "<", + filters: "<" + customFilters: "<" + selectedFilters: "<" + }, + bindToController: true, + controller: "Filter", + controllerAs: "vm", + templateUrl: 'components/filter/filter.html', + link: link + } + +angular.module('taigaComponents').directive("tgFilter", [FilterDirective]) diff --git a/app/modules/components/filter/filter.jade b/app/modules/components/filter/filter.jade new file mode 100644 index 00000000..05760586 --- /dev/null +++ b/app/modules/components/filter/filter.jade @@ -0,0 +1,110 @@ +h1 + span.title(translate="COMMON.FILTERS.TITLE") + +form + fieldset + input.e2e-filter-q( + type="text", + placeholder="{{'COMMON.FILTERS.INPUT_PLACEHOLDER' | translate}}", + ng-model="vm.q" + ng-model-options="{ debounce: 200 }" + ng-change="vm.changeQ()" + ) + tg-svg.search-action( + svg-icon="icon-search", + title="{{'COMMON.FILTERS.TITLE_ACTION_SEARCH' | translate}}" + ) + +.filters-step-cat + .filters-applied + .single-filter.ng-animate-disabled(ng-repeat="it in vm.selectedFilters track by it.key") + span.name(ng-attr-style="{{it.color ? 'border-left: 3px solid ' + it.color: ''}}") {{it.name}} + a.remove-filter.e2e-remove-filter( + ng-click="vm.unselectFilter(it)" + href="" + ) + tg-svg(svg-icon="icon-close") + + a.button.button-gray.save-filters.ng-animate-disabled.e2e-open-custom-filter-form( + ng-click="vm.customFilterForm = true" + ng-if="vm.selectedFilters.length && !vm.customFilterForm" + href="", + title="{{'COMMON.SAVE' | translate}}", + translate="COMMON.FILTERS.ACTION_SAVE_CUSTOM_FILTER" + ) + + form( + ng-if="vm.customFilterForm" + ng-submit="vm.saveCustomFilter()" + ) + input.my-filter-name.e2e-filter-name-input( + tg-autofocus + ng-model="vm.customFilterName" + type="text" + placeholder="{{'COMMON.FILTERS.PLACEHOLDER_FILTER_NAME' | translate}}" + ) + + .filters-cats + ul + li( + ng-class="{selected: vm.isOpen(filter.dataType)}" + ng-repeat="filter in vm.filters track by filter.dataType" + ) + a.filters-cat-single.e2e-category( + ng-class="{selected: vm.isOpen(filter.dataType)}" + ng-click="vm.toggleFilterCategory(filter.dataType)" + href="" + title="{{::filter.title}}" + ) + span.title {{::filter.title}} + tg-svg.ng-animate-disabled( + ng-if="!vm.isOpen(filter.dataType)" + svg-icon="icon-arrow-right" + ) + tg-svg.ng-animate-disabled( + ng-if="vm.isOpen(filter.dataType)" + svg-icon="icon-arrow-down" + ) + + .filter-list( + ng-if="vm.isOpen(filter.dataType)", + tg-filter-slide-down + ) + .single-filter.ng-animate-disabled( + ng-repeat="it in filter.content" + ng-if="!vm.isFilterSelected(filter, it) && !(it.count == 0 && filter.hideEmpty)" + ng-click="vm.selectFilter(filter, it)" + ) + span.name(ng-attr-style="{{it.color ? 'border-left: 3px solid ' + it.color: ''}}") {{it.name}} + span.number.e2e-filter-count(ng-if="it.count > 0") {{it.count}} + + li.custom-filters.e2e-custom-filters(ng-class="{selected: vm.isOpen('custom-filter')}") + a.filters-cat-single( + ng-class="{selected: vm.isOpen('custom-filter')}" + ng-click="vm.toggleFilterCategory('custom-filter')" + href="" + title="{{'COMMON.FILTERS.CATEGORIES.CUSTOM_FILTERS' | translate}}" + ) + span.title(translate="COMMON.FILTERS.CATEGORIES.CUSTOM_FILTERS") + tg-svg.ng-animate-disabled( + ng-if="!vm.isOpen('custom-filter')" + svg-icon="icon-arrow-right" + ) + tg-svg.ng-animate-disabled( + ng-if="vm.isOpen('custom-filter')" + svg-icon="icon-arrow-down" + ) + .filter-list( + ng-if="vm.isOpen('custom-filter')", + tg-filter-slide-down + ) + .single-filter.ng-animate-disabled.e2e-custom-filter( + ng-repeat="it in vm.customFilters" + ng-click="vm.selectCustomFilter(it)" + ) + span.name {{it.name}} + a.remove-filter.e2e-remove-custom-filter( + ng-click="vm.removeCustomFilter(it)" + href="" + ) + tg-svg(svg-icon="icon-trash") diff --git a/app/modules/components/filter/filter.scss b/app/modules/components/filter/filter.scss new file mode 100644 index 00000000..ad2e2a7f --- /dev/null +++ b/app/modules/components/filter/filter.scss @@ -0,0 +1,150 @@ +tg-filter { + background-color: $whitish; + display: block; + left: 0; + min-height: 100%; + padding: 1rem 0; + position: absolute; + top: 0; + width: 260px; + z-index: 1; + .filters-applied { + padding: 0 1rem 1rem; + } + h1, + form { + padding: 0 1rem; + } + input { + background: $grayer; + color: $white; + @include placeholder { + color: $gray-light; + } + } + .search-action { + position: absolute; + right: .7rem; + top: .7rem; + } + &.ng-hide-add { + transform: translateX(0); + transition-duration: .5s; + } + &.ng-hide-add-active { + transform: translateX(-260px); + } + &.ng-hide-remove { + transform: translateX(-260px); + transition-duration: .5s; + } + &.ng-hide-remove-active { + transform: translateX(0); + } +} + +.filter-list { + display: none; + overflow-y: auto; + padding: 1rem; +} + +.filters-step-cat { + margin-top: 2rem; +} + +.filters-cats { + ul { + margin-bottom: 0; + } + li { + border-bottom: 1px solid $gray-light; + text-transform: uppercase; + &.selected { + border-bottom: 0; + } + } + .custom-filters { + .title { + color: $primary; + } + } + .filters-cat-single { + align-items: center; + color: $grayer; + display: flex; + justify-content: space-between; + padding: .5rem .5rem .5rem 1.5rem; + transition: color .2s ease-in; + &:hover, + &.selected { + background-color: rgba(darken($whitish, 20%), 1); + color: $grayer; + transition: background-color .2s ease-in; + .icon { + opacity: 1; + transition: opacity .2s ease-in; + } + } + } + .icon-arrow-down { + fill: currentColor; + float: right; + height: .9rem; + opacity: 0; + transition: opacity .2s ease-in; + width: .9rem; + } +} + +.single-filter { + @include font-type(text); + @include clearfix; + align-items: center; + background: darken($whitish, 10%); // Fallback + cursor: pointer; + display: flex; + justify-content: space-between; + margin-bottom: .5rem; + opacity: .5; + padding-right: .5rem; + position: relative; + &:hover { + color: $grayer; + opacity: 1; + transition: opacity .2s linear; + } + &.selected, + &.active { + color: $grayer; + opacity: 1; + transition: opacity .2s linear; + } + .name, + .number { + padding: 8px 10px; + } + .name { + @include ellipsis(100%); + display: block; + width: 100%; + } + .number { + background: darken($whitish, 20%); // Fallback + position: absolute; + right: 0; + top: 0; + } + .remove-filter { + display: block; + svg { + fill: $gray; + transition: fill .2s linear; + } + &:hover { + svg { + fill: $red; + } + } + } +} diff --git a/app/modules/components/joy-ride/joy-ride.service.coffee b/app/modules/components/joy-ride/joy-ride.service.coffee index 396ec30d..bf22b54b 100644 --- a/app/modules/components/joy-ride/joy-ride.service.coffee +++ b/app/modules/components/joy-ride/joy-ride.service.coffee @@ -138,7 +138,7 @@ class JoyRideService extends taiga.Service if @checkPermissionsService.check('add_us') steps.push({ - element: '.icon-plus', + element: '.add-action', position: 'bottom', joyride: { title: @translate.instant('JOYRIDE.KANBAN.STEP3.TITLE') diff --git a/app/modules/components/kanban-board-zoom/kanban-board-zoom.directive.coffee b/app/modules/components/kanban-board-zoom/kanban-board-zoom.directive.coffee new file mode 100644 index 00000000..26e60a0f --- /dev/null +++ b/app/modules/components/kanban-board-zoom/kanban-board-zoom.directive.coffee @@ -0,0 +1,69 @@ +### +# Copyright (C) 2014-2016 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: kanban-board-zoom.directive.coffee +### + +KanbanBoardZoomDirective = (storage, projectService) -> + link = (scope, el, attrs, ctrl) -> + scope.zoomIndex = storage.get("kanban_zoom") or 2 + scope.levels = 5 + + zooms = [ + ["ref"], + ["subject"], + ["owner", "tags", "extra_info", "unfold"], + ["attachments"], + ["related_tasks"] + ] + + getZoomView = (zoomIndex = 0) -> + if storage.get("kanban_zoom") != zoomIndex + storage.set("kanban_zoom", zoomIndex) + + return _.reduce zooms, (result, value, key) -> + if key <= zoomIndex + result = result.concat(value) + + return result + + scope.$watch 'zoomIndex', (zoomLevel) -> + zoom = getZoomView(zoomLevel) + scope.onZoomChange({zoomLevel: zoomLevel, zoom: zoom}) + + unwatch = scope.$watch () -> + return projectService.project + , (project) -> + if project + if project.get('my_permissions').indexOf("view_tasks") == -1 + scope.levels = 4 + unwatch() + + return { + scope: { + onZoomChange: "&" + }, + template: """ + + """, + link: link + } + +angular.module('taigaComponents').directive("tgKanbanBoardZoom", ["$tgStorage", "tgProjectService", KanbanBoardZoomDirective]) diff --git a/app/modules/components/taskboard-zoom/taskboard-zoom.directive.coffee b/app/modules/components/taskboard-zoom/taskboard-zoom.directive.coffee new file mode 100644 index 00000000..7db01d43 --- /dev/null +++ b/app/modules/components/taskboard-zoom/taskboard-zoom.directive.coffee @@ -0,0 +1,62 @@ +### +# Copyright (C) 2014-2016 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: taskboard-zoom.directive.coffee +### + +TaskboardZoomDirective = (storage) -> + link = (scope, el, attrs, ctrl) -> + scope.zoomIndex = storage.get("taskboard_zoom") or 2 + + scope.levels = 4 + + zooms = [ + ["ref"], + ["subject"], + ["owner", "tags", "extra_info", "unfold"], + ["attachments"], + ["related_tasks"] + ] + + getZoomView = (zoomIndex = 0) -> + if storage.get("taskboard_zoom") != zoomIndex + storage.set("taskboard_zoom", zoomIndex) + + return _.reduce zooms, (result, value, key) -> + if key <= zoomIndex + result = result.concat(value) + + return result + + scope.$watch 'zoomIndex', (zoomLevel) -> + zoom = getZoomView(zoomLevel) + scope.onZoomChange({zoomLevel: zoomLevel, zoom: zoom}) + + return { + scope: { + onZoomChange: "&" + }, + template: """ + + """, + link: link + } + +angular.module('taigaComponents').directive("tgTaskboardZoom", ["$tgStorage", TaskboardZoomDirective]) diff --git a/app/partials/backlog/backlog.jade b/app/partials/backlog/backlog.jade index def38f1d..75ca752c 100644 --- a/app/partials/backlog/backlog.jade +++ b/app/partials/backlog/backlog.jade @@ -3,8 +3,21 @@ doctype html div.wrapper(tg-backlog, ng-controller="BacklogController as ctrl", ng-init="section='backlog'") tg-project-menu - sidebar.menu-secondary.extrabar.filters-bar(tg-backlog-filters) - include ../includes/modules/backlog-filters + + sidebar.backlog-filter + tg-filter( + q="ctrl.filterQ" + filters="ctrl.filters" + custom-filters="ctrl.customFilters" + selected-filters="ctrl.selectedFilters" + customFilters="ctl.customFilters" + on-save-custom-filter="ctrl.saveCustomFilter(name)" + on-add-filter="ctrl.addFilter(filter)" + on-select-custom-filter="ctrl.selectCustomFilter(filter)" + on-remove-custom-filter="ctrl.removeCustomFilter(filter)" + on-remove-filter="ctrl.removeFilter(filter)" + on-change-q="ctrl.changeQ(q)" + ) section.main.backlog include ../includes/components/mainTitle @@ -39,13 +52,20 @@ 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( - ng-if="userstories.length" + a.trans-button.e2e-open-filter( + ng-if="!ctrl.activeFilters" href="" title="{{'BACKLOG.FILTERS.TOGGLE' | translate}}" id="show-filters-button" translate="BACKLOG.FILTERS.SHOW" ) + a.trans-button.active.e2e-open-filter( + ng-if="ctrl.activeFilters" + href="" + title="{{'BACKLOG.FILTERS.HIDE' | translate}}" + id="show-filters-button" + translate="BACKLOG.FILTERS.HIDE" + ) a.trans-button( ng-if="userstories.length" href="" diff --git a/app/partials/backlog/filter-selected.jade b/app/partials/backlog/filter-selected.jade deleted file mode 100644 index 29e0a12e..00000000 --- a/app/partials/backlog/filter-selected.jade +++ /dev/null @@ -1,9 +0,0 @@ -<% _.each(filters, function(f) { %> -.single-filter.selected( - data-type!="<%- f.type %>" - data-id!="<%- f.id %>" -) - span.name(style!="<%- f.style %>") <%- f.name %> - a.remove-filter(href="") - tg-svg(svg-icon="icon-close") -<% }) %> diff --git a/app/partials/backlog/filters.jade b/app/partials/backlog/filters.jade deleted file mode 100644 index 1d504108..00000000 --- a/app/partials/backlog/filters.jade +++ /dev/null @@ -1,17 +0,0 @@ -<% _.each(filters, function(f) { %> -<% if (f.selected) { %> -a.single-filter.active(data-type!="<%- f.type %>", data-id!="<%- f.id %>") - span.name(style!="<%- f.style %>") - | <%- f.name %> - <% if (f.count){ %> - span.number <%- f.count %> - <% } %> -<% } else { %> -a.single-filter(data-type!="<%- f.type %>", data-id!="<%- f.id %>") - span.name(style!="<%- f.style %>") - | <%- f.name %> - <% if (f.count){ %> - span.number <%- f.count %> - <% } %> -<% } %> -<% }) %> \ No newline at end of file diff --git a/app/partials/includes/components/taskboard-task.jade b/app/partials/includes/components/taskboard-task.jade deleted file mode 100644 index 07808869..00000000 --- a/app/partials/includes/components/taskboard-task.jade +++ /dev/null @@ -1,18 +0,0 @@ -div.taskboard-tagline(tg-colorize-tags="task.tags", tg-colorize-tags-type="taskboard") -div.taskboard-task-inner - div.taskboard-user-avatar(tg-taskboard-user-avatar, users="usersById", task="task", project="project") - tg-svg.iocaine( - ng-if="task.is_iocaine" - svg-icon="icon-iocaine", - svg-title="{{'COMMON.IOCAINE_TEXT' | translate}}" - ) - p.taskboard-text - a.task-assigned(href="", title="{{'TASKBOARD.TITLE_ACTION_ASSIGN' | translate}}") - span.task-num(tg-bo-ref="task.ref") - a.task-name(href="", title="#{{ ::task.ref }} {{ ::task.subject }}", ng-bind="task.subject", - tg-nav="project-tasks-detail:project=project.slug,ref=task.ref") - tg-svg.edit-task( - tg-check-permission="modify_task" - svg-icon="icon-edit", - svg-title-translate="TASKBOARD.TITLE_ACTION_EDIT" - ) diff --git a/app/partials/includes/modules/backlog-filters.jade b/app/partials/includes/modules/backlog-filters.jade deleted file mode 100644 index 6198bfaa..00000000 --- a/app/partials/includes/modules/backlog-filters.jade +++ /dev/null @@ -1,36 +0,0 @@ -section.filters - div.filters-inner - h1 - span.title(translate="COMMON.FILTERS.TITLE") - - form - fieldset - input(type="text", placeholder="{{'COMMON.FILTERS.INPUT_PLACEHOLDER' | translate}}", ng-model="filtersQ") - tg-svg.search-action( - svg-icon="icon-search", - title="{{'COMMON.FILTERS.TITLE_ACTION_FILTER_BUTTON' | translate}}" - ) - - div.filters-step-cat - div.filters-applied - h2.hidden.breadcrumb - a.back( - href="" - title="{{'COMMON.FILTERS.BREADCRUMB_TITLE' | translate}}" - translate="BACKLOG.FILTERS.TITLE" - ) - tg-svg(svg-icon="icon-arrow-right") - a.subfilter(href="") - span.title(translate="COMMON.FILTERS.BREADCRUMB_STATUS") - div.filters-cats - ul - li - a(href="", title="{{'BACKLOG.FILTERS.FILTER_CATEGORY_STATUS' | translate}}", data-type="status") - span.title(translate="BACKLOG.FILTERS.FILTER_CATEGORY_STATUS") - tg-svg(svg-icon="icon-arrow-right") - li - a(href="", title="{{'BACKLOG.FILTERS.FILTER_CATEGORY_TAGS' | translate}}", data-type="tags") - span.title(translate="BACKLOG.FILTERS.FILTER_CATEGORY_TAGS") - tg-svg(svg-icon="icon-arrow-right") - - div.filter-list.hidden diff --git a/app/partials/includes/modules/issues-filters.jade b/app/partials/includes/modules/issues-filters.jade deleted file mode 100644 index 1dd12fa8..00000000 --- a/app/partials/includes/modules/issues-filters.jade +++ /dev/null @@ -1,84 +0,0 @@ -section.filters - div.filters-inner - h1 - span.title(translate="ISSUES.FILTERS.TITLE") - form - fieldset - input(type="text", placeholder="{{'ISSUES.FILTERS.INPUT_SEARCH_PLACEHOLDER' | translate}}", - ng-model="filtersQ") - tg-svg.search-action(svg-icon="icon-search", title="{{'ISSUES.FILTERS.TITLE_ACTION_SEARCH' | translate}}") - div.filters-step-cat - div.filters-applied - a.hide.button.button-gray.save-filters(href="", title="{{'COMMON.SAVE' | translate}}", ng-class="{hide: filters.length}", translate="ISSUES.FILTERS.ACTION_SAVE_CUSTOM_FILTER") - h2.hidden.breadcrumb - a.back(href="", title="{{'ISSUES.FILTERS.TITLE_BREADCRUMB' | translate}}", translate="ISSUES.FILTERS.BREADCRUMB") - tg-svg(svg-icon="icon-arrow-right") - a.subfilter(href="", title="cat-name") - span.title(translate="COMMON.FILTERS.BREADCRUMB_STATUS") - div.filters-cats - ul - li - a.filters-cat-single( - href="" - title="{{ 'ISSUES.FILTERS.CATEGORIES.TYPE' | translate}}" - data-type="types" - ) - span.title(translate="ISSUES.FILTERS.CATEGORIES.TYPE") - tg-svg(svg-icon="icon-arrow-right") - li - a.filters-cat-single( - href="" - title="{{ 'ISSUES.FILTERS.CATEGORIES.STATUS' | translate}}" - data-type="status" - ) - span.title(translate="ISSUES.FILTERS.CATEGORIES.STATUS") - tg-svg(svg-icon="icon-arrow-right") - li - a.filters-cat-single( - href="" - title="{{ 'ISSUES.FILTERS.CATEGORIES.SEVERITY' | translate}}" - data-type="severities" - ) - span.title(translate="ISSUES.FILTERS.CATEGORIES.SEVERITY") - tg-svg(svg-icon="icon-arrow-right") - li - a.filters-cat-single( - href="" - title="{{ 'ISSUES.FILTERS.CATEGORIES.PRIORITIES' | translate}}" - data-type="priorities" - ) - span.title(translate="ISSUES.FILTERS.CATEGORIES.PRIORITIES") - tg-svg(svg-icon="icon-arrow-right") - li - a.filters-cat-single( - href="" - title="{{ 'ISSUES.FILTERS.CATEGORIES.TAGS' | translate}}" - data-type="tags" - ) - span.title(translate="ISSUES.FILTERS.CATEGORIES.TAGS") - tg-svg(svg-icon="icon-arrow-right") - li - a.filters-cat-single(href="" - title="{{ 'ISSUES.FILTERS.CATEGORIES.ASSIGNED_TO' | translate}}" - data-type="assignedTo" - ) - span.title(translate="ISSUES.FILTERS.CATEGORIES.ASSIGNED_TO") - tg-svg(svg-icon="icon-arrow-right") - li - a.filters-cat-single( - href="" - title="{{ 'ISSUES.FILTERS.CATEGORIES.CREATED_BY' | translate}}" - data-type="createdBy" - ) - span.title(translate="ISSUES.FILTERS.CATEGORIES.CREATED_BY") - tg-svg(svg-icon="icon-arrow-right") - li.custom-filters(ng-if="filters.myFilters.length") - a.filters-cat-single( - href="" - title="{{ 'ISSUES.FILTERS.CATEGORIES.CUSTOM_FILTERS' | translate}}" - data-type="myFilters" - ) - span.title(translate="ISSUES.FILTERS.CATEGORIES.CUSTOM_FILTERS") - tg-svg(svg-icon="icon-arrow-right") - - div.filter-list.hidden diff --git a/app/partials/includes/modules/issues-table.jade b/app/partials/includes/modules/issues-table.jade index 06b0a9f2..186629c8 100644 --- a/app/partials/includes/modules/issues-table.jade +++ b/app/partials/includes/modules/issues-table.jade @@ -1,13 +1,21 @@ section.issues-table.basic-table(ng-class="{empty: !issues.length}") div.row.title - div.level-field(data-fieldname="type", translate="ISSUES.TABLE.COLUMNS.TYPE") - div.level-field(data-fieldname="severity", translate="ISSUES.TABLE.COLUMNS.SEVERITY") - div.level-field(data-fieldname="priority", translate="ISSUES.TABLE.COLUMNS.PRIORITY") - div.votes(data-fieldname="total_voters", translate="ISSUES.TABLE.COLUMNS.VOTES") - div.subject(data-fieldname="subject", translate="ISSUES.TABLE.COLUMNS.SUBJECT") - div.issue-field(data-fieldname="status", translate="ISSUES.TABLE.COLUMNS.STATUS") - div.created-field(data-fieldname="created_date", translate="ISSUES.TABLE.COLUMNS.CREATED") - div.assigned-field(data-fieldname="assigned_to", translate="ISSUES.TABLE.COLUMNS.ASSIGNED_TO") + div.level-field(data-fieldname="type") + | {{"ISSUES.TABLE.COLUMNS.TYPE" | translate}} + div.level-field(data-fieldname="severity") + | {{"ISSUES.TABLE.COLUMNS.SEVERITY" | translate}} + div.level-field(data-fieldname="priority") + | {{"ISSUES.TABLE.COLUMNS.PRIORITY" | translate}} + div.votes(data-fieldname="total_voters") + | {{"ISSUES.TABLE.COLUMNS.VOTES" | translate}} + div.subject(data-fieldname="subject") + | {{"ISSUES.TABLE.COLUMNS.SUBJECT" | translate}} + div.issue-field(data-fieldname="status") + | {{"ISSUES.TABLE.COLUMNS.STATUS" | translate}} + div.created-field(data-fieldname="created_date") + | {{"ISSUES.TABLE.COLUMNS.CREATED" | translate}} + div.assigned-field(data-fieldname="assigned_to") + | {{"ISSUES.TABLE.COLUMNS.ASSIGNED_TO" | translate}} div.row.table-main( ng-repeat="issue in issues track by issue.id" diff --git a/app/partials/includes/modules/kanban-table.jade b/app/partials/includes/modules/kanban-table.jade index c904c56d..12c396c1 100644 --- a/app/partials/includes/modules/kanban-table.jade +++ b/app/partials/includes/modules/kanban-table.jade @@ -1,8 +1,13 @@ -div.kanban-table(tg-kanban-squish-column, tg-kanban-sortable) +div.kanban-table( + tg-kanban-squish-column, + tg-kanban-sortable, + ng-class="{'zoom-0': ctrl.zoomLevel == 0}" +) div.kanban-table-header div.kanban-table-inner h2.task-colum-name(ng-repeat="s in usStatusList track by s.id", - ng-style="{'border-top-color':s.color}", tg-bo-title="s.name", + ng-style="{'border-top-color':s.color}", + tg-bo-title="s.name", ng-class='{vfold:folds[s.id]}', tg-class-permission="{'readonly': '!modify_task'}") span(tg-bo-bind="s.name") @@ -21,21 +26,6 @@ div.kanban-table(tg-kanban-squish-column, tg-kanban-sortable) ng-class='{hidden:!folds[s.id]}' ) tg-svg(svg-icon="icon-unfold-column") - a.option( - href="" - title="{{'KANBAN.TITLE_ACTION_FOLD_CARDS' | translate}}" - ng-class="{hidden:statusViewModes[s.id] == 'minimized'}" - ng-click="ctrl.updateStatusViewMode(s.id, 'minimized')" - ) - tg-svg.fold-action(svg-icon="icon-fold-row") - a.option( - href="" - title="{{'KANBAN.TITLE_ACTION_UNFOLD_CARDS' | translate}}" - ng-class="{hidden:statusViewModes[s.id] == 'maximized'}" - ng-click="ctrl.updateStatusViewMode(s.id, 'maximized')" - ) - tg-svg.fold-action(svg-icon="icon-unfold-row") - a.option( href="" title="{{'KANBAN.TITLE_ACTION_ADD_US' | translate}}" @@ -65,18 +55,29 @@ div.kanban-table(tg-kanban-squish-column, tg-kanban-sortable) div.kanban-table-body div.kanban-table-inner div.kanban-uses-box.task-column(ng-class='{vfold:folds[s.id]}', - ng-repeat="s in usStatusList track by s.id", + ng-repeat="s in ::usStatusList track by s.id", tg-kanban-wip-limit="s", tg-kanban-column-height-fixer, tg-bind-scope ) - div.kanban-task( - ng-repeat="us in usByStatus[s.id] track by us.id", - tg-kanban-userstory, - ng-model="us", - tg-bind-scope, - tg-class-permission="{'readonly': '!modify_task'}" - ng-class="{'kanban-task-maximized': ctrl.isMaximized(s.id), 'kanban-task-minimized': ctrl.isMinimized(s.id), 'card-placeholder': us.isPlaceholder}" - placeholder="{{us.isPlaceholder}}" + .card-placeholder( + ng-if="ctrl.showPlaceHolder(s.id)" + ng-include="'common/components/kanban-placeholder.html'" ) + + tg-card.card.ng-animate-disabled( + tg-repeat="us in usByStatus.get(s.id.toString()) track by us.getIn(['model', 'id'])", + ng-class="{'kanban-task-maximized': ctrl.isMaximized(s.id), 'kanban-task-minimized': ctrl.isMinimized(s.id)}" + tg-class-permission="{'readonly': '!modify_task'}" + tg-bind-scope, + on-toggle-fold="ctrl.toggleFold(id)" + on-click-edit="ctrl.editUs(id)" + on-click-assigned-to="ctrl.changeUsAssignedTo(id)" + project="project" + item="us" + zoom="ctrl.zoom" + zoom-level="ctrl.zoomLevel" + archived="ctrl.isUsInArchivedHiddenStatus(us.get('id'))" + ) + div.kanban-column-intro(ng-if="s.is_archived", tg-kanban-archived-status-intro="s") diff --git a/app/partials/includes/modules/taskboard-table.jade b/app/partials/includes/modules/taskboard-table.jade index 16eff4f9..3ad5325b 100644 --- a/app/partials/includes/modules/taskboard-table.jade +++ b/app/partials/includes/modules/taskboard-table.jade @@ -1,8 +1,18 @@ -div.taskboard-table(tg-taskboard-squish-column, tg-taskboard-sortable) +div.taskboard-table( + tg-taskboard-squish-column, + tg-taskboard-sortable, + ng-class="{'zoom-0': ctrl.zoomLevel == 0}" +) div.taskboard-table-header div.taskboard-table-inner h2.task-colum-name(translate="TASKBOARD.TABLE.COLUMN") - h2.task-colum-name(ng-repeat="s in taskStatusList track by s.id", ng-style="{'border-top-color':s.color}", ng-class="{'column-fold':statusesFolded[s.id]}", class="squish-status-{{s.id}}", tg-bo-title="s.name") + h2.task-colum-name( + ng-repeat="s in ::taskStatusList track by s.id" + ng-style="{'border-top-color':s.color}" + ng-class="{'column-fold':statusesFolded[s.id]}" + class="squish-status-{{s.id}}" + tg-bo-title="s.name" + ) span(tg-bo-bind="s.name") tg-svg.hfold.fold-action( @@ -49,20 +59,31 @@ div.taskboard-table(tg-taskboard-squish-column, tg-taskboard-sortable) span(translate="TASKBOARD.TABLE.FIELD_POINTS") include ../components/addnewtask - div.taskboard-tasks-box.task-column(ng-repeat="st in taskStatusList track by st.id", class="squish-status-{{st.id}}", ng-class="{'column-fold':statusesFolded[st.id]}", tg-bind-scope) - div.taskboard-task( - ng-repeat="task in usTasks[us.id][st.id] track by task.id" - tg-bind-scope - tg-class-permission="{'readonly': '!modify_task'}" - ng-class="{'card-placeholder': task.isPlaceholder}" + + div.taskboard-tasks-box.task-column( + ng-repeat="st in ::taskStatusList track by st.id", + class="squish-status-{{st.id}}", + ng-class="{'column-fold':statusesFolded[st.id]}", + tg-bind-scope + ) + .card-placeholder( + ng-if="ctrl.showPlaceHolder(st.id, us.id)" + ng-include="'common/components/taskboard-placeholder.html'" + ) + tg-card.card.ng-animate-disabled( + tg-repeat="task in usTasks.getIn([us.id.toString(), st.id.toString()]) track by task.get('id')" + ng-class="{'kanban-task-maximized': ctrl.isMaximized(s.id), 'kanban-task-minimized': ctrl.isMinimized(s.id)}" + tg-class-permission="{'readonly': '!modify_task'}" + tg-bind-scope, + on-toggle-fold="ctrl.toggleFold(id)" + on-click-edit="ctrl.editTask(id)" + on-click-assigned-to="ctrl.changeTaskAssignedTo(id)" + project="project" + item="task" + zoom="ctrl.zoom" + zoom-level="ctrl.zoomLevel" + type="task" ) - div(ng-if="!task.isPlaceholder", tg-taskboard-task) - include ../components/taskboard-task - - div(ng-if="task.isPlaceholder") - - var card = 'task' - include ../../common/components/taskboard-placeholder - div.task-row(ng-init="us = null", ng-class="{'row-fold':usFolded[null]}") div.taskboard-userstory-box.task-column a.vfold( @@ -82,15 +103,29 @@ div.taskboard-table(tg-taskboard-squish-column, tg-taskboard-sortable) h3.us-title span(translate="TASKBOARD.TABLE.ROW_UNASSIGED_TASKS_TITLE") include ../components/addnewtask.jade - div.taskboard-tasks-box.task-column(ng-repeat="st in taskStatusList track by st.id", class="squish-status-{{st.id}}", ng-class="{'column-fold':statusesFolded[st.id]}", tg-bind-scope) - div.taskboard-task( - ng-repeat="task in usTasks[null][st.id] track by task.id" - tg-bind-scope - tg-class-permission="{'readonly': '!modify_task'}" - ng-class="{'card-placeholder': task.isPlaceholder}" - ) - div(ng-if="!task.isPlaceholder", tg-taskboard-task) - include ../components/taskboard-task - div(ng-if="task.isPlaceholder") - include ../../common/components/taskboard-placeholder + div.taskboard-tasks-box.task-column( + ng-repeat="st in ::taskStatusList track by st.id", + class="squish-status-{{st.id}}", + ng-class="{'column-fold':statusesFolded[st.id]}", + tg-bind-scope + ) + .card-placeholder( + ng-if="ctrl.showPlaceHolder(st.id, us.id)" + ng-include="'common/components/taskboard-placeholder.html'" + ) + + tg-card.card.ng-animate-disabled( + tg-bind-scope, + tg-repeat="task in usTasks.getIn(['null', st.id.toString()]) track by task.get('id')" + ng-class="{'kanban-task-maximized': ctrl.isMaximized(s.id), 'kanban-task-minimized': ctrl.isMinimized(s.id)}" + tg-class-permission="{'readonly': '!modify_task'}" + on-toggle-fold="ctrl.toggleFold(id)" + on-click-edit="ctrl.editTask(id)" + on-click-assigned-to="ctrl.changeTaskAssignedTo(id)" + project="project" + item="task" + zoom="ctrl.zoom" + zoom-level="ctrl.zoomLevel" + type="task" + ) diff --git a/app/partials/issue/issues-filters-selected.jade b/app/partials/issue/issues-filters-selected.jade deleted file mode 100644 index 29e0a12e..00000000 --- a/app/partials/issue/issues-filters-selected.jade +++ /dev/null @@ -1,9 +0,0 @@ -<% _.each(filters, function(f) { %> -.single-filter.selected( - data-type!="<%- f.type %>" - data-id!="<%- f.id %>" -) - span.name(style!="<%- f.style %>") <%- f.name %> - a.remove-filter(href="") - tg-svg(svg-icon="icon-close") -<% }) %> diff --git a/app/partials/issue/issues-filters.jade b/app/partials/issue/issues-filters.jade deleted file mode 100644 index 5c26bdd5..00000000 --- a/app/partials/issue/issues-filters.jade +++ /dev/null @@ -1,21 +0,0 @@ -<% _.each(filters, function(f) { %> -<% if (!f.selected) { %> -.single-filter( - data-type!="<%- f.type %>" - data-id!="<%- f.id %>" -) - span.name(style!="<%- f.style %>") <%- f.name %> - <% if (f.count){ %> - span.number <%- f.count %> - <% } %> - <% if (f.type == "myFilters"){ %> - a.remove-filter(href="") - tg-svg(svg-icon="icon-trash") - <% } %> -<% } %> -<% }) %> -span(class="new") -input.hidden.my-filter-name( - type="text" - placeholder="{{'ISSUES.PLACEHOLDER_FILTER_NAME' | translate}}" -) diff --git a/app/partials/issue/issues.jade b/app/partials/issue/issues.jade index 2e6adb2b..97df1b10 100644 --- a/app/partials/issue/issues.jade +++ b/app/partials/issue/issues.jade @@ -6,8 +6,20 @@ div.wrapper.issues.lightbox-generic-form( ng-init="section='issues'" ) tg-project-menu - sidebar.menu-secondary.extrabar.filters-bar(tg-issues-filters) - include ../includes/modules/issues-filters + sidebar.filters-bar + tg-filter( + q="ctrl.filterQ" + filters="ctrl.filters" + custom-filters="ctrl.customFilters" + selected-filters="ctrl.selectedFilters" + customFilters="ctl.customFilters" + on-save-custom-filter="ctrl.saveCustomFilter(name)" + on-add-filter="ctrl.addFilter(filter)" + on-select-custom-filter="ctrl.selectCustomFilter(filter)" + on-remove-custom-filter="ctrl.removeCustomFilter(filter)" + on-remove-filter="ctrl.removeFilter(filter)" + on-change-q="ctrl.changeQ(q)" + ) section.main.issues-page header diff --git a/app/partials/kanban/kanban-task.jade b/app/partials/kanban/kanban-task.jade deleted file mode 100644 index 46fed783..00000000 --- a/app/partials/kanban/kanban-task.jade +++ /dev/null @@ -1,33 +0,0 @@ -div.kanban-tagline( - tg-colorize-tags="us.tags" - tg-colorize-tags-type="kanban" - ng-hide="us.isArchived" -) -div.kanban-task-inner(ng-class="{'task-archived': us.isArchived}") - div.avatar-wrapper(tg-kanban-user-avatar="us.assigned_to", ng-model="us", ng-hide="us.isArchived") - div.task-text(ng-hide="us.isArchived") - a.task-assigned(href="", title="{{'US.ASSIGN' | translate}}") - span.task-num(tg-bo-ref="us.ref") - a.task-name(href="", title="#{{ ::us.ref }} {{ us.subject }}", ng-bind="us.subject", - tg-nav="project-userstories-detail:project=project.slug,ref=us.ref", - tg-nav-get-params="{\"kanban-status\": {{us.status}}}") - - p.task-points(href="", title="{{'US.TOTAL_US_POINTS' | translate}}") - span(ng-if="us.total_points !== null", ng-bind="us.total_points") - span.points-text(ng-if="us.total_points !== null", translate="COMMON.FIELDS.POINTS") - span(ng-if="us.total_points === null", translate="US.NOT_ESTIMATED") - - div.task-archived-text(ng-show="us.isArchived") - p(translate="KANBAN.ARCHIVED") - p - span.task-num(tg-bo-ref="us.ref") - span.task-name(ng-bind="us.subject") - p(translate="KANBAN.UNDO_ARCHIVED") - - a.edit-us( - href="", - title="{{'COMMON.EDIT' | translate}}", - tg-check-permission="modify_us", - ng-hide="us.isArchived" - ) - tg-svg(svg-icon="icon-edit") diff --git a/app/partials/kanban/kanban.jade b/app/partials/kanban/kanban.jade index 99c05aa1..885fb31e 100644 --- a/app/partials/kanban/kanban.jade +++ b/app/partials/kanban/kanban.jade @@ -5,7 +5,35 @@ div.wrapper(tg-kanban, ng-controller="KanbanController as ctrl" tg-project-menu section.main.kanban - include ../includes/components/mainTitle + tg-filter( + ng-show="ctrl.openFilter" + q="ctrl.filterQ" + filters="ctrl.filters" + custom-filters="ctrl.customFilters" + selected-filters="ctrl.selectedFilters" + customFilters="ctl.customFilters" + on-save-custom-filter="ctrl.saveCustomFilter(name)" + on-add-filter="ctrl.addFilter(filter)" + on-select-custom-filter="ctrl.selectCustomFilter(filter)" + on-remove-custom-filter="ctrl.removeCustomFilter(filter)" + on-remove-filter="ctrl.removeFilter(filter)" + on-change-q="ctrl.changeQ(q)" + ) + + .kanban-header + include ../includes/components/mainTitle + .taskboard-actions + tg-kanban-board-zoom( + ng-if="usByStatus.size", + on-zoom-change="ctrl.setZoom(zoomLevel, zoom)" + ) + + button.button-filter.e2e-open-filter( + ng-class="{'button-filters-applied': !!ctrl.selectedFilters.length}" + ng-click="ctrl.openFilter = !ctrl.openFilter" + ) + tg-svg(svg-icon="icon-filters") + include ../includes/modules/kanban-table div.lightbox.lightbox-generic-form.lb-create-edit-userstory(tg-lb-create-edit-userstory) diff --git a/app/partials/taskboard/taskboard.jade b/app/partials/taskboard/taskboard.jade index 8a12aaf1..81274531 100644 --- a/app/partials/taskboard/taskboard.jade +++ b/app/partials/taskboard/taskboard.jade @@ -4,11 +4,38 @@ div.wrapper(tg-taskboard, ng-controller="TaskboardController as ctrl", ng-init="section='backlog'") tg-project-menu section.main.taskboard - .taskboard-inner + tg-filter( + ng-show="ctrl.openFilter" + q="ctrl.filterQ" + filters="ctrl.filters" + custom-filters="ctrl.customFilters" + selected-filters="ctrl.selectedFilters" + customFilters="ctl.customFilters" + on-save-custom-filter="ctrl.saveCustomFilter(name)" + on-add-filter="ctrl.addFilter(filter)" + on-select-custom-filter="ctrl.selectCustomFilter(filter)" + on-remove-custom-filter="ctrl.removeCustomFilter(filter)" + on-remove-filter="ctrl.removeFilter(filter)" + on-change-q="ctrl.changeQ(q)" + ) + .taskboard-header h1 span(tg-bo-bind="project.name", class="project-name-short") span.green(tg-bo-bind="sprint.name") span.date(tg-date-range="sprint.estimated_start,sprint.estimated_finish") + .taskboard-actions + tg-taskboard-zoom( + ng-if="usTasks.size", + on-zoom-change="ctrl.setZoom(zoomLevel, zoom)" + ) + button.button-filter.e2e-open-filter( + ng-class="{'button-filters-applied': !!ctrl.selectedFilters.length}" + ng-click="ctrl.openFilter = !ctrl.openFilter" + ) + tg-svg(svg-icon="icon-filters") + + .taskboard-inner + include ../includes/components/sprint-summary div.graphics-container diff --git a/app/styles/components/buttons.scss b/app/styles/components/buttons.scss index b1f34cfd..2a6d9e27 100755 --- a/app/styles/components/buttons.scss +++ b/app/styles/components/buttons.scss @@ -155,3 +155,14 @@ a.button-gray { display: inline-block; margin-top: .5rem; } + +.button-filter { + @extend %button; + background: $whitish; + margin-left: 1rem; + padding: .4rem .5rem; + &:hover { + background: $gray-light; + fill: $whitish; + } +} diff --git a/app/styles/components/filter.scss b/app/styles/components/filter.scss deleted file mode 100644 index c681c70d..00000000 --- a/app/styles/components/filter.scss +++ /dev/null @@ -1,50 +0,0 @@ -.single-filter { - @include font-type(text); - @include clearfix; - align-items: center; - background: darken($whitish, 10%); // Fallback - display: flex; - justify-content: space-between; - margin-bottom: .5rem; - opacity: .5; - padding-right: .5rem; - position: relative; - &:hover { - color: $grayer; - opacity: 1; - transition: opacity .2s linear; - } - &.selected, - &.active { - color: $grayer; - opacity: 1; - transition: opacity .2s linear; - } - .name, - .number { - padding: 8px 10px; - } - .name { - @include ellipsis(100%); - display: block; - width: 100%; - } - .number { - background: darken($whitish, 20%); // Fallback - position: absolute; - right: 0; - top: 0; - } - .remove-filter { - display: block; - svg { - fill: $gray; - transition: fill .2s linear; - } - &:hover { - svg { - fill: $red; - } - } - } -} diff --git a/app/styles/components/kanban-task.scss b/app/styles/components/kanban-task.scss deleted file mode 100644 index 12a0de21..00000000 --- a/app/styles/components/kanban-task.scss +++ /dev/null @@ -1,224 +0,0 @@ -.kanban-task { - background: $card; - border: 1px solid $card-hover; - box-shadow: none; - cursor: move; - margin: .2rem; - position: relative; - &:last-child { - margin-bottom: 0; - } - &:hover { - .edit-us { - display: block; - fill: $card-dark; - opacity: 1; - transition: color .3s linear, opacity .3s linear; - } - } - &.gu-mirror { - box-shadow: 1px 1px 15px rgba($black, .4); - opacity: 1; - transition: box-shadow .3s linear; - } - &.blocked { - background: $red; - border: 1px solid darken($red, 10%); - color: $white; - a, - span { - color: $white; - } - } - &.card-placeholder { - background: darken($whitish, 2%); - border: 3px dashed darken($whitish, 8%); - cursor: default; - } - .kanban-tagline { - border-color: $card-hover; - display: flex; - height: .6rem; - } - .kanban-tag { - border-top: .3rem solid $card-hover; - flex-basis: 0; - flex-grow: 1; - height: .6rem; - z-index: 90; - } - .kanban-task-inner { - display: flex; - padding: .5rem; - } - .avatar-wrapper { - flex-basis: 55px; - flex-grow: 0; - flex-shrink: 0; - width: 55px; - img { - width: 100%; - } - } - .avatar { - a { - @include font-size(small); - text-align: center; - } - img { - margin: 0 auto; - &:hover { - border: 2px solid $primary; - transition: border .3s linear; - } - } - } - .task-text { - @include font-size(small); - flex-grow: 1; - padding: 0 .5rem 0 .8rem; - } - .task-assigned { - color: $card-dark; - display: block; - } - .task-num { - color: $grayer; - margin-right: .3rem; - } - .task-name { - @include font-type(bold); - } - .loading { - bottom: .5rem; - position: absolute; - } - .edit-us { - display: block; - opacity: 0; - position: absolute; - svg { - @include svg-size(1.1rem); - fill: $card-hover; - } - &:hover { - cursor: pointer; - svg { - fill: darken($card-hover, 15%); - transition: color .3s linear; - } - } - } -} - - -.kanban-task-maximized { - .task-archived { - background: darken($whitish, 5%); - padding: .5rem; - text-align: left; - transition: background .3s linear; - &:hover { - background: darken($whitish, 8%); - transition: background .3s linear; - } - .task-archived-text { - flex: 1; - } - span { - color: $gray-light; - } - p { - @include font-size(small); - color: $gray-light; - margin: 0; - &:last-child { - color: $gray; - margin: .5rem 0; - text-align: center; - } - } - } - .task-name { - word-wrap: break-word; - } - .loading, - .edit-us { - bottom: .2rem; - right: .5rem; - } - .task-points { - @include font-size(small); - color: darken($card-hover, 15%); - margin: 0; - span { - display: inline-block; - &:first-child { - padding-right: .2rem; - } - } - .points-text { - text-transform: lowercase; - } - } - .kanban-tag { - border-top: .3rem solid; - } -} - -.kanban-task-minimized { - .kanban-task-inner { - padding: 0 .3rem; - } - .task-archived { - @include font-size(small); - background: darken($whitish, 5%); - padding: .3rem; - text-align: left; - .task-archived-text { - flex: 1; - } - span { - color: $gray-light; - } - .task-name { - display: inline-block; - max-width: 70%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - p { - color: $gray-light; - margin: 0; - &:last-child { - display: none; - } - } - } - .task-num { - vertical-align: top; - } - .task-name { - display: inline-block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - width: 135px; - } - .task-points { - display: none; - } - .icon-edit { - bottom: .2rem; - right: 1rem; - top: 1.4rem; - } - .kanban-tag { - border-top: .2rem solid; - } - .edit-us { - bottom: .2rem; - right: .5rem; - } -} diff --git a/app/styles/components/taskboard-task.scss b/app/styles/components/taskboard-task.scss deleted file mode 100644 index 7a9e6516..00000000 --- a/app/styles/components/taskboard-task.scss +++ /dev/null @@ -1,140 +0,0 @@ -.taskboard-task { - background: $card; - border: 1px solid $card-hover; - box-shadow: none; - cursor: move; - margin: .2rem; - position: relative; - &:hover { - .icon-edit { - display: block; - fill: $card-dark; - opacity: 1; - transition: color .3s linear, opacity .3s linear; - } - } - &.gu-mirror { - box-shadow: 1px 1px 15px rgba($black, .4); - transition: box-shadow .3s linear; - } - .blocked { - background: $red; - border: 1px solid darken($red, 10%); - color: $white; - svg, - span { - color: $white; - fill: $white; - } - &:hover { - .icon-edit { - fill: currentColor; - } - } - } - &.card-placeholder { - background: darken($whitish, 2%); - border: 3px dashed darken($whitish, 8%); - cursor: default; - } - .taskboard-tagline { - border-color: $card-hover; - display: flex; - height: .6rem; - } - .taskboard-tag { - border-top: .3rem solid $card-hover; - flex-basis: 0; - flex-grow: 1; - height: .6rem; - z-index: 90; - } - .taskboard-task-inner { - display: flex; - padding: .5rem; - } - .taskboard-user-avatar { - flex-basis: 50px; - flex-grow: 1; - max-width: 55px; - a { - @include font-size(small); - display: block; - text-align: center; - } - img { - margin: 0 auto; - &:hover { - border: 2px solid $primary; - transition: border .3s linear; - } - } - } - .iocaine { - left: .2rem; - position: absolute; - top: 1rem; - img { - filter: hue-rotate(150deg) saturate(200%); - } - } - .icon-iocaine { - background: $black; - border-radius: 5px; - fill: $white; - height: 1.75rem; - padding: .25rem; - width: 1.75rem; - } - .task-assigned { - @include font-size(small); - color: $card-dark; - display: block; - &:hover { - color: $primary; - } - } - .task-num { - color: $grayer; - margin-right: .5em; - } - .task-name { - @include font-type(bold); - } - .taskboard-text { - @include font-size(small); - flex-basis: 50px; - flex-grow: 10; - padding: 0 .5rem 0 1rem; - word-wrap: break-word; - } - .icon { - transition: color .3s linear, opacity .3s linear; - } - .loading { - bottom: .5rem; - position: absolute; - } - .edit-task { - bottom: .5rem; - position: absolute; - top: auto; - } - .icon-edit { - @include svg-size(1.1rem); - cursor: pointer; - fill: $card-hover; - opacity: 0; - &:hover { - fill: $card-dark; - } - } - .icon-edit, - .loading { - right: 1rem; - } -} - -.task-drag { - @include box-shadow(); -} diff --git a/app/styles/core/base.scss b/app/styles/core/base.scss index b42e403f..7b4af1d2 100644 --- a/app/styles/core/base.scss +++ b/app/styles/core/base.scss @@ -56,21 +56,6 @@ body { min-width: 0; padding: 1rem; width: 320px; - &.filters-bar { - flex: 0 0 auto; - padding: 0; - transition: all .2s linear; - width: 0; - &.active { - padding: 2em 1em; - transition: all .2s linear; - width: 260px; - .filters-inner { - opacity: 1; - transition: all .4s ease-in; - } - } - } .search-in { margin-top: .5rem; } diff --git a/app/styles/dependencies/helpers.scss b/app/styles/dependencies/helpers.scss index ed6cb729..4ea9378f 100644 --- a/app/styles/dependencies/helpers.scss +++ b/app/styles/dependencies/helpers.scss @@ -1,2 +1,2 @@ $navbar: 40px; -$main-height: calc(100vh - 40px); +$main-height: calc(100vh - #{$navbar}); diff --git a/app/styles/layout/backlog.scss b/app/styles/layout/backlog.scss index ec8c3afb..310e5467 100644 --- a/app/styles/layout/backlog.scss +++ b/app/styles/layout/backlog.scss @@ -1,3 +1,24 @@ +.backlog-filter { + align-items: stretch; + display: flex; + opacity: 0; + overflow: hidden; + position: relative; + transition: all .2s linear; + width: 0; + tg-filter { + transform: translateX(-260px); + transition: all .2s linear; + } + &.active { + opacity: 1; + transition: all .2s linear; + width: 260px; + tg-filter { + transform: translateX(0); + } + } +} .backlog-menu { background: $mass-white; color: $blackish; diff --git a/app/styles/layout/issues.scss b/app/styles/layout/issues.scss index 1c122c2f..52e9deff 100644 --- a/app/styles/layout/issues.scss +++ b/app/styles/layout/issues.scss @@ -1,10 +1,6 @@ .issues { .filters-bar { - flex: 0 0 auto; + position: relative; width: 260px; } - .filters-inner { - opacity: 1; - padding: 1rem; - } } diff --git a/app/styles/layout/kanban.scss b/app/styles/layout/kanban.scss index ce1cb08d..11a7ea77 100644 --- a/app/styles/layout/kanban.scss +++ b/app/styles/layout/kanban.scss @@ -4,6 +4,7 @@ height: $main-height; max-height: $main-height; max-width: calc(100vw - 50px); + position: relative; header { min-height: 70px; } @@ -14,3 +15,12 @@ display: none; } } + +.kanban-header { + align-items: center; + display: flex; + justify-content: space-between; + .options { + display: flex; + } +} diff --git a/app/styles/layout/taskboard.scss b/app/styles/layout/taskboard.scss index 7c64a5d5..e213b56f 100644 --- a/app/styles/layout/taskboard.scss +++ b/app/styles/layout/taskboard.scss @@ -1,6 +1,7 @@ .taskboard { height: $main-height; overflow: hidden; + position: relative; h1, .graphics-container, .summary { @@ -11,6 +12,12 @@ } } +.taskboard-header { + align-items: center; + display: flex; + justify-content: space-between; +} + .taskboard-inner { display: flex; flex-direction: column; diff --git a/app/styles/modules/backlog/taskboard-table.scss b/app/styles/modules/backlog/taskboard-table.scss index 54509fbb..77467948 100644 --- a/app/styles/modules/backlog/taskboard-table.scss +++ b/app/styles/modules/backlog/taskboard-table.scss @@ -3,27 +3,29 @@ $column-width: 300px; $column-flex: 1; $column-shrink: 0; -$column-margin: 0 10px 0 0; +$column-margin: 0 5px 0 0; +$column-padding: .5rem 1rem; @mixin fold { - .taskboard-task { - background: none; - border: 0; - margin: 0; - min-height: 0; - .taskboard-task-inner { - padding: .1rem; - } - .taskboard-tagline, - .taskboard-text { + .card { + align-self: flex-start; + margin-top: .5rem; + tg-card-slideshow, + .card-unfold, + .card-tag, + .card-title, + .card-owner-actions, + .card-data, + .card-statistics, + .card-owner-name { display: none; } - .avatar { - height: 35px; - width: 35px; - } - .icon { - display: none; + .card-owner { + img { + height: 1.3rem; + margin-right: 0; + width: 1.3rem; + } } } &.task-column, @@ -44,25 +46,20 @@ $column-margin: 0 10px 0 0; .taskboard-table { display: flex; flex-direction: column; - height: 100%; overflow: hidden; width: 100%; - .taskboard-task { - &.readonly { - cursor: auto; - } - &.gu-mirror { - opacity: 1; - .avatar-task-link { - display: none; - } + &.zoom-0 { + .task-colum-name span { + padding-right: 1rem; } } } .taskboard-table-header { - margin-bottom: .5rem; - min-height: 40px; + flex-basis: 38px; + flex-grow: 0; + flex-shrink: 0; + min-height: 38px; position: relative; width: 100%; .taskboard-table-inner { @@ -83,7 +80,7 @@ $column-margin: 0 10px 0 0; justify-content: space-between; margin: $column-margin; max-width: $column-width; - padding: .5rem 1rem; + padding: $column-padding; position: relative; text-transform: uppercase; width: $column-width; @@ -102,6 +99,9 @@ $column-margin: 0 10px 0 0; margin: 0; } } + span { + @include ellipsis(65%); + } } tg-svg { display: block; @@ -128,7 +128,8 @@ $column-margin: 0 10px 0 0; } .taskboard-table-body { - height: 100%; + flex: 1; + margin-bottom: 5rem; overflow: auto; width: 100%; .task-column { @@ -147,14 +148,10 @@ $column-margin: 0 10px 0 0; } .column-fold { @include fold; - .taskboard-task { - max-width: 40px; - width: 40px; - } } .task-row { display: flex; - margin-bottom: .5rem; + margin-bottom: .25rem; min-height: 10rem; width: 100%; &.blocked { @@ -167,6 +164,7 @@ $column-margin: 0 10px 0 0; .points-value, .points-value:hover { color: $white; + fill: $white; transition: color .3s linear; } .taskboard-tasks-box { @@ -185,18 +183,26 @@ $column-margin: 0 10px 0 0; } } } - .taskboard-userstory-box { padding: .5rem .5rem .5rem 1.5rem; } - .avatar-task-link { - display: none; + +} + +.taskboard-userstory-box { + position: relative; + .us-title { + @include font-size(normal); + @include font-type(text); + margin-bottom: 0; + margin-right: 3rem; } - .avatar-assigned-to { - display: block; - } - .icon { - transition: fill .2s linear; + .points-value { + @include font-size(small); + color: $gray-light; + span { + margin-right: .1rem; + } } tg-svg { cursor: pointer; @@ -219,20 +225,3 @@ $column-margin: 0 10px 0 0; } } } - -.taskboard-userstory-box { - position: relative; - .us-title { - @include font-size(normal); - @include font-type(text); - margin-bottom: 0; - margin-right: 3rem; - } - .points-value { - @include font-size(small); - color: $gray-light; - span { - margin-right: .1rem; - } - } -} diff --git a/app/styles/modules/filters/filters.scss b/app/styles/modules/filters/filters.scss deleted file mode 100644 index bc938262..00000000 --- a/app/styles/modules/filters/filters.scss +++ /dev/null @@ -1,114 +0,0 @@ -.filters { - h1 { - vertical-align: baseline; - .icon { - margin: 0; - } - a { - vertical-align: baseline; - } - } - .breadcrumb { - @include font-size(large); - margin-top: 1rem; - .icon-arrow-right { - @include svg-size(.7rem); - margin: 0 .25rem; - vertical-align: middle; - } - .back { - color: $gray-light; - } - } - input { - background: $grayer; - color: $white; - @include placeholder { - color: $gray-light; - } - } - .search-action { - position: absolute; - right: .7rem; - top: .7rem; - } -} - -.filters-inner { - opacity: 0; - transition: all .1s ease-in; - .loading { - margin: 0; - padding: 8px; - text-align: center; - width: 100%; - .loading-spinner { - @include loading-spinner; - max-height: 1rem; - max-width: 1rem; - } - } -} - -.filters-applied { - margin-top: .5rem; -} - -.filters-step-cat { - .save-filters { - color: $white; - display: block; - text-align: center; - } - .my-filter-name { - background: $grayer; - color: $whitish; - width: 100%; - @include placeholder { - color: $gray-light; - } - } -} - -.filter-list { - .single-filter { - cursor: pointer; - } -} - -.filters-cats { - margin-top: 2rem; - li { - border-bottom: 1px solid $gray-light; - text-transform: uppercase; - } - .custom-filters { - .title { - color: $primary; - } - } - a { - align-items: center; - color: $grayer; - display: flex; - justify-content: space-between; - padding: .5rem 0 .5rem .5rem; - transition: color .2s ease-in; - &:hover { - color: $primary; - transition: color .2s ease-in; - .icon { - opacity: 1; - transition: opacity .2s ease-in; - } - } - } - .icon { - fill: currentColor; - float: right; - height: .9rem; - opacity: 0; - transition: opacity .2s ease-in; - width: .9rem; - } -} diff --git a/app/styles/modules/kanban/kanban-table.scss b/app/styles/modules/kanban/kanban-table.scss index 8c1930eb..2dd442ce 100644 --- a/app/styles/modules/kanban/kanban-table.scss +++ b/app/styles/modules/kanban/kanban-table.scss @@ -1,10 +1,11 @@ //Table basic shared vars -$column-width: 300px; +$column-width: 296px; $column-folded-width: 30px; $column-flex: 0; $column-shrink: 0; -$column-margin: 0 10px 0 0; +$column-margin: 0 5px 0 0; +$column-padding: .5rem 1rem; .kanban-table { display: flex; @@ -12,7 +13,19 @@ $column-margin: 0 10px 0 0; height: 100%; overflow: hidden; width: 100%; + &.zoom-0 { + .task-column, + .task-colum-name { + max-width: $column-width / 2; + } + .task-colum-name span { + padding-right: 1rem; + } + } .vfold { + tg-card { + display: none; + } &.task-colum-name { align-items: center; display: flex; @@ -36,9 +49,6 @@ $column-margin: 0 10px 0 0; min-width: $column-folded-width; width: $column-folded-width; } - .kanban-task { - display: none; - } .kanban-column-intro { display: none; } @@ -46,11 +56,11 @@ $column-margin: 0 10px 0 0; .readonly { cursor: auto; } + } .kanban-table-header { - margin-bottom: .5rem; - min-height: 40px; + min-height: 38px; position: relative; width: 100%; .kanban-table-inner { @@ -58,6 +68,9 @@ $column-margin: 0 10px 0 0; overflow: hidden; position: absolute; } + .options { + display: flex; + } .task-colum-name { @include font-size(medium); align-items: center; @@ -71,7 +84,7 @@ $column-margin: 0 10px 0 0; justify-content: space-between; margin: $column-margin; max-width: $column-width; - padding: .5rem .5rem .5rem 1rem; + padding: $column-padding; position: relative; text-transform: uppercase; &:last-child { @@ -110,6 +123,7 @@ $column-margin: 0 10px 0 0; max-width: $column-width; overflow-y: auto; widows: $column-width; + width: $column-width; &:last-child { margin-right: 0; } diff --git a/app/styles/shame/shame.scss b/app/styles/shame/shame.scss index bdd3b817..5a238d13 100644 --- a/app/styles/shame/shame.scss +++ b/app/styles/shame/shame.scss @@ -28,14 +28,8 @@ a[ng-click] svg { } // chrome url break -.kanban-task { - .task-name { +tg-card { + .card-title span:last-child { word-break: break-word; } } - -.taskboard-task { - .task-name { - word-break: break-word; - } -} \ No newline at end of file diff --git a/app/svg/sprite.svg b/app/svg/sprite.svg index 0a5d3ec6..ff8ef151 100644 --- a/app/svg/sprite.svg +++ b/app/svg/sprite.svg @@ -429,5 +429,14 @@ fill="#fff" d="M511.998 107.939c-222.856 0-404.061 181.204-404.061 404.061s181.205 404.061 404.061 404.061c222.856 0 404.061-181.203 404.061-404.061s-181.205-404.061-404.061-404.061zM511.998 158.447c88.671 0 169.621 32.484 231.616 86.222l-498.947 498.948c-53.74-61.998-86.223-142.945-86.223-231.617 0-195.561 157.992-353.553 353.553-353.553zM779.328 280.383c53.74 61.998 86.223 142.945 86.223 231.617 0 195.561-157.992 353.553-353.553 353.553-88.671 0-169.617-32.484-231.616-86.222l498.947-498.948z"> + + Add user + + + + View more + + diff --git a/e2e/helpers/filters-helper.js b/e2e/helpers/filters-helper.js new file mode 100644 index 00000000..38cac018 --- /dev/null +++ b/e2e/helpers/filters-helper.js @@ -0,0 +1,81 @@ +var utils = require('../utils'); + +var helper = module.exports; + +helper.getFilter = function() { + return $('tg-filter'); +}; + +helper.open = async function() { + let isPresent = await $('.e2e-open-filter').isPresent(); + + if(isPresent) { + $('.e2e-open-filter').click(); + } else { + return; + } + + var filter = helper.getFilter(); + + return utils.common.transitionend('.e2e-open-filter') +}; + +helper.byText = function(text) { + return $('.e2e-filter-q').sendKeys(text); +}; + +helper.clearByTextInput = function() { + return utils.common.clear($('.e2e-filter-q')); +}; + +helper.clearFilters = async function() { + let filters = $$('.e2e-remove-filter'); + let filtersSize = await filters.count() + + for(var i = 0; i < filtersSize; i++) { + filters.get(i).click(); + } + + await helper.clearByTextInput(); + let isPresent = await $('.e2e-category.selected').isPresent(); + + if(isPresent) { + $('.e2e-category.selected').click(); + } +}; + +helper.getFiltersCounters = function() { + return $$('.e2e-filter-count'); +}; + +helper.getCustomFilters = function() { + return $$('.e2e-custom-filter'); +}; + +helper.firterByLastCustomFilter = function() { + helper.openCustomFiltersCategory(); + helper.getCustomFilters().last().click(); +}; + +helper.openCustomFiltersCategory = function() { + $('.e2e-custom-filters').click(); +}; + +helper.removeLastCustomFilter = function() { + $$('.e2e-remove-custom-filter').last().click(); +} + +helper.firterByCategoryWithContent = function() { + $$('.e2e-category').first().click(); + + let filter = helper.getFiltersCounters().first().element(by.xpath('..')); + + return filter.click(); +}; + +helper.saveFilter = async function(name) { + $('.e2e-open-custom-filter-form').click(); + + await $('.e2e-filter-name-input').sendKeys(name); + await $('.e2e-filter-name-input').sendKeys(protractor.Key.ENTER); +}; diff --git a/e2e/helpers/issues-helper.js b/e2e/helpers/issues-helper.js index 2308fa26..85022ebe 100644 --- a/e2e/helpers/issues-helper.js +++ b/e2e/helpers/issues-helper.js @@ -90,57 +90,3 @@ helper.parseIssue = async function(elm) { return obj; }; - -helper.getFilterInput = function() { - return $$('sidebar[tg-issues-filters] input').get(0); -}; - -helper.filtersCats = function() { - return $$('.filters-cats li'); -}; - -helper.filtersList = function() { - return $$('.filter-list .single-filter'); -}; - -helper.selectFilter = async function(index) { - helper.filtersList().get(index).click(); -}; - -helper.saveFilter = async function(name) { - $('.filters-step-cat .save-filters').click(); - - await $('.filter-list input').sendKeys(name); - - return browser.actions().sendKeys(protractor.Key.ENTER).perform(); -}; - -helper.backToFilters = function() { - $$('.breadcrumb a').get(0).click(); -}; - -helper.removeFilters = async function() { - let count = await $$('.filters-applied .single-filter.selected').count(); - - while(count) { - $$('.single-filter.selected').get(0).$('.remove-filter').click(); - - count = await $$('.single-filter.selected').count(); - } -}; - -helper.getCustomFilters = function() { - return $$('.filter-list div[data-type="myFilters"]'); -}; - -helper.removeCustomFilters = async function() { - let count = await $$('.filter-list .remove-filter').count(); - - while(count) { - $$('.filter-list .remove-filter').get(0).click(); - - await utils.lightbox.confirm.ok(); - - count = await $$('.filter-list .remove-filter').count(); - } -}; diff --git a/e2e/helpers/kanban-helper.js b/e2e/helpers/kanban-helper.js index a40703fb..a84460ca 100644 --- a/e2e/helpers/kanban-helper.js +++ b/e2e/helpers/kanban-helper.js @@ -15,15 +15,31 @@ helper.getColumns = function() { }; helper.getColumnUssTitles = function(column) { - return helper.getColumns().$$('.task-name').getText(); + return helper.getColumns().$$('.e2e-title').getText(); }; helper.getBoxUss = function(column) { - return helper.getColumns().get(column).$$('.kanban-task'); + return helper.getColumns().get(column).$$('tg-card'); }; -helper.editUs = function(column, us) { - helper.getColumns().get(column).$$('.edit-us').get(us).click(); +helper.getUss = function() { + return $$('tg-card') +}; + +helper.editUs = async function(column, us) { + let editionZone = helper.getColumns().get(column).$$('.card-owner-actions').get(us); + + await browser + .actions() + .mouseMove(editionZone) + .perform(); + + return browser + .actions() + .mouseMove(editionZone) + .mouseMove(editionZone.$('.e2e-edit')) + .click() + .perform(); }; helper.openBulkUsLb = function(column) { @@ -59,5 +75,13 @@ helper.scrollRight = function() { }; helper.watchersLinks = function() { - return $$('.task-assigned'); + return $$('.e2e-assign'); +}; + +helper.zoom = async function(level) { + return browser + .actions() + .mouseMove($('tg-board-zoom'), {y: 14, x: level * 49}) + .click() + .perform(); }; diff --git a/e2e/helpers/taskboard-helper.js b/e2e/helpers/taskboard-helper.js index 084d4f6f..734a2d58 100644 --- a/e2e/helpers/taskboard-helper.js +++ b/e2e/helpers/taskboard-helper.js @@ -13,7 +13,11 @@ helper.getBox = function(row, column) { helper.getBoxTasks = function(row, column) { let box = helper.getBox(row, column); - return box.$$('.taskboard-task'); + return box.$$('tg-card'); +}; + +helper.getTasks = function() { + return $$('tg-card'); }; helper.openNewTaskLb = function(row) { @@ -52,8 +56,20 @@ helper.unFoldColumn = function(row) { icon.click(); }; -helper.editTask = function(row, column, task) { - helper.getBoxTasks(row, column).get(task).$('.edit-task').click(); +helper.editTask = async function(row, column, task) { + let editionZone = helper.getBoxTasks(row, column).$$('.card-owner-actions').get(task); + + await browser + .actions() + .mouseMove(editionZone) + .perform(); + + return browser + .actions() + .mouseMove(editionZone) + .mouseMove(editionZone.$('.e2e-edit')) + .click() + .perform(); }; helper.toggleGraph = function() { @@ -114,5 +130,13 @@ helper.getBulkCreateTask = function() { }; helper.watchersLinks = function() { - return $$('.task-assigned'); + return $$('.e2e-assign'); +}; + +helper.zoom = async function(level) { + return browser + .actions() + .mouseMove($('tg-board-zoom'), {y: 10, x: level * 74}) + .click() + .perform(); }; diff --git a/e2e/shared/filters.js b/e2e/shared/filters.js new file mode 100644 index 00000000..54db32c4 --- /dev/null +++ b/e2e/shared/filters.js @@ -0,0 +1,76 @@ +var filterHelper = require('../helpers/filters-helper'); +var utils = require('../utils'); + +var chai = require('chai'); +var chaiAsPromised = require('chai-as-promised'); + +chai.use(chaiAsPromised); +var expect = chai.expect; + +module.exports = function(name, counter) { + before(async () => { + await filterHelper.open(); + + utils.common.takeScreenshot(name, 'filters'); + }); + + it('filter by ref', async () => { + await filterHelper.byText('xxxxyy123123123'); + + let len = await counter(); + len = await counter(); + + await filterHelper.clearFilters(); + + expect(len).to.be.equal(0); + }); + + it('filter by category', async () => { + let len = await counter(); + + await filterHelper.firterByCategoryWithContent(); + + let newLength = await counter(); + + expect(len).to.be.above(newLength); + + await filterHelper.clearFilters(); + + newLength = await counter(); + + expect(len).to.be.equal(newLength); + }); + + it('save custom filters', async () => { + let len = await counter(); + + filterHelper.openCustomFiltersCategory(); + + let customFiltersSize = await filterHelper.getCustomFilters().count(); + + await filterHelper.firterByCategoryWithContent(); + await filterHelper.saveFilter("custom-filter"); + await filterHelper.clearFilters(); + await filterHelper.firterByLastCustomFilter(); + + let newLength = await counter(); + let newCustomFiltersSize = await filterHelper.getCustomFilters().count(); + + expect(newLength).to.be.below(len); + expect(newCustomFiltersSize).to.be.equal(customFiltersSize + 1); + + await filterHelper.clearFilters(); + }); + + it('remove custom filters', async () => { + filterHelper.openCustomFiltersCategory(); + + let customFiltersSize = await filterHelper.getCustomFilters().count(); + + filterHelper.removeLastCustomFilter(); + + let newCustomFiltersSize = await filterHelper.getCustomFilters().count(); + + expect(newCustomFiltersSize).to.be.equal(customFiltersSize - 1); + }); +}; diff --git a/e2e/suites/backlog.e2e.js b/e2e/suites/backlog.e2e.js index b88b5db9..dce0a0a5 100644 --- a/e2e/suites/backlog.e2e.js +++ b/e2e/suites/backlog.e2e.js @@ -5,6 +5,8 @@ var commonHelper = require('../helpers').common; var chai = require('chai'); var chaiAsPromised = require('chai-as-promised'); +var sharedFilters = require('../shared/filters'); + chai.use(chaiAsPromised); var expect = chai.expect; @@ -243,7 +245,7 @@ describe('backlog', function() { expect(elementRef1).to.be.equal(draggedRefs[1]); }); - it.only('drag multiple us to milestone', async function() { + it('drag multiple us to milestone', async function() { let sprint = backlogHelper.sprints().get(0); let initUssSprintCount = await backlogHelper.getSprintUsertories(sprint).count(); @@ -453,143 +455,9 @@ describe('backlog', function() { }); }); - describe('filters', function() { - it('show filters', async function() { - let transition = utils.common.transitionend('.menu-secondary.filters-bar', 'opacity'); - - $('#show-filters-button').click(); - - await transition(); - - utils.common.takeScreenshot('backlog', 'backlog-filters'); - }); - - it('filter by subject', async function() { - let usCount = await backlogHelper.userStories().count(); - let filterQ = element(by.model('filtersQ')); - - let htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body'); - - await filterQ.sendKeys('add'); - - await htmlChanges(); - - let newUsCount = await backlogHelper.userStories().count(); - - expect(newUsCount).to.be.below(usCount); - - htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body'); - - // clear status - await filterQ.clear(); - - await htmlChanges(); - }); - - it('filter by ref', async function() { - let userstories = backlogHelper.userStories(); - let filterQ = element(by.model('filtersQ')); - let htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body'); - - let ref = await backlogHelper.getTestingFilterRef(); - - ref = ref.replace('#', ''); - - await filterQ.sendKeys(ref); - await htmlChanges(); - - let newUsCount = await userstories.count(); - expect(newUsCount).to.be.equal(1); - - htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body'); - - // clear status - await filterQ.clear(); - - await htmlChanges(); - }); - - it('filter by status', async function() { - let usCount = await backlogHelper.userStories().count(); - - let htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body'); - - $$('.filters-cats a').first().click(); - $$('.filter-list a').first().click(); - - await htmlChanges(); - - let newUsCount = await backlogHelper.userStories().count(); - - expect(newUsCount).to.be.below(usCount); - - //remove status - htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body'); - - $$('.filters-applied a').first().click(); - - await htmlChanges(); - - newUsCount = await backlogHelper.userStories().count(); - - expect(newUsCount).to.be.equal(usCount); - - backlogHelper.goBackFilters(); - }); - - it('filter by tags', async function() { - let usCount = await backlogHelper.userStories().count(); - let htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body'); - - $$('.filters-cats a').get(1).click(); - await browser.waitForAngular(); - - $$('.filter-list a').first().click(); - - await htmlChanges(); - - let newUsCount = await backlogHelper.userStories().count(); - - expect(newUsCount).to.be.below(usCount); - - //remove tags - htmlChanges = await utils.common.outerHtmlChanges('.backlog-table-body'); - - $$('.filters-applied a').first().click(); - - await htmlChanges(); - - newUsCount = await backlogHelper.userStories().count(); - - expect(newUsCount).to.be.equal(usCount); - }); - - it('trying drag with filters open', async function() { - let dragableElements = backlogHelper.userStories(); - let dragElement = dragableElements.get(5); - - await utils.common.drag(dragElement, dragableElements.get(0)); - - let waitErrorOpen = await utils.notifications.error.open(); - - expect(waitErrorOpen).to.be.true; - - await utils.notifications.error.close(); - }); - - it('hide filters', async function() { - let menu = $('.menu-secondary.filters-bar'); - let transition = utils.common.transitionend('.menu-secondary.filters-bar', 'width'); - - $('#show-filters-button').click(); - - await transition(); - - let waitWidth = await menu.getCssValue('width'); - - expect(waitWidth).to.be.equal('0px'); - }); - }); + describe('backlog filters', sharedFilters.bind(this, 'backlog', () => { + return backlogHelper.userStories().count(); + })); describe('closed sprints', function() { async function createEmptyMilestone() { diff --git a/e2e/suites/issues/issues.e2e.js b/e2e/suites/issues/issues.e2e.js index 809d2003..785badac 100644 --- a/e2e/suites/issues/issues.e2e.js +++ b/e2e/suites/issues/issues.e2e.js @@ -1,6 +1,7 @@ var utils = require('../../utils'); var issuesHelper = require('../../helpers').issues; var commonHelper = require('../../helpers').common; +var sharedFilters = require('../../shared/filters'); var chai = require('chai'); var chaiAsPromised = require('chai-as-promised'); @@ -126,195 +127,7 @@ describe('issues list', function() { expect(issueUserName).to.be.equal(newUserName); }); - describe('filters', function() { - it('by ref', async function() { - let table = issuesHelper.getTable(); - let issues = issuesHelper.getIssues(); - let issue = issues.get(0); - issue = await issuesHelper.parseIssue(issue); - let filterInput = issuesHelper.getFilterInput(); - - let htmlChanges = await utils.common.outerHtmlChanges(table); - await filterInput.sendKeys(issue.ref); - await htmlChanges(); - - let newIssuesCount = await issues.count(); - - expect(newIssuesCount).to.be.equal(1); - - htmlChanges = await utils.common.outerHtmlChanges(table); - await utils.common.clear(filterInput); - await htmlChanges(); - }); - - it('by subject', async function() { - let table = issuesHelper.getTable(); - let issues = issuesHelper.getIssues(); - let issue = issues.get(0); - issue = await issuesHelper.parseIssue(issue); - let filterInput = issuesHelper.getFilterInput(); - - let oldIssuesCount = await $$('.row.table-main').count(); - - let htmlChanges = await utils.common.outerHtmlChanges(table); - await filterInput.sendKeys(issue.subject); - await htmlChanges(); - - let newIssuesCount = await issues.count(); - - expect(newIssuesCount).not.to.be.equal(oldIssuesCount); - expect(newIssuesCount).to.be.above(0); - - htmlChanges = await utils.common.outerHtmlChanges(table); - await utils.common.clear(filterInput); - await htmlChanges(); - }); - - it('by type', async function() { - let table = issuesHelper.getTable(); - - let htmlChanges = await utils.common.outerHtmlChanges(table); - issuesHelper.filtersCats().get(0).$('a').click(); - issuesHelper.selectFilter(0); - - await htmlChanges(); - - issuesHelper.backToFilters(); - - await issuesHelper.removeFilters(); - }); - - it('by status', async function() { - let table = issuesHelper.getTable(); - - let htmlChanges = await utils.common.outerHtmlChanges(table); - issuesHelper.filtersCats().get(1).$('a').click(); - issuesHelper.selectFilter(0); - await htmlChanges(); - - issuesHelper.backToFilters(); - - await issuesHelper.removeFilters(); - }); - - it('by severity', async function() { - let table = issuesHelper.getTable(); - - let htmlChanges = await utils.common.outerHtmlChanges(table); - issuesHelper.filtersCats().get(2).$('a').click(); - issuesHelper.selectFilter(0); - await htmlChanges(); - - issuesHelper.backToFilters(); - - await issuesHelper.removeFilters(); - }); - - it('by priorities', async function() { - let table = issuesHelper.getTable(); - - let htmlChanges = await utils.common.outerHtmlChanges(table); - issuesHelper.filtersCats().get(3).$('a').click(); - issuesHelper.selectFilter(0); - await htmlChanges(); - - issuesHelper.backToFilters(); - - await issuesHelper.removeFilters(); - }); - - it('by tags', async function() { - let table = issuesHelper.getTable(); - - let htmlChanges = await utils.common.outerHtmlChanges(table); - issuesHelper.filtersCats().get(4).$('a').click(); - issuesHelper.selectFilter(1); - await htmlChanges(); - - issuesHelper.backToFilters(); - - await issuesHelper.removeFilters(); - }); - - it('by assigned to', async function() { - let table = issuesHelper.getTable(); - - let htmlChanges = await utils.common.outerHtmlChanges(table); - issuesHelper.filtersCats().get(5).$('a').click(); - issuesHelper.selectFilter(0); - await htmlChanges(); - - issuesHelper.backToFilters(); - - await issuesHelper.removeFilters(); - }); - - it('by created by', async function() { - let table = issuesHelper.getTable(); - - let htmlChanges = await utils.common.outerHtmlChanges(table); - issuesHelper.filtersCats().get(6).$('a').click(); - issuesHelper.selectFilter(0); - await htmlChanges(); - - issuesHelper.backToFilters(); - - await issuesHelper.removeFilters(); - }); - - it('empty', async function() { - let table = issuesHelper.getTable(); - let htmlChanges = await utils.common.outerHtmlChanges(table); - - let filterInput = issuesHelper.getFilterInput(); - - await filterInput.sendKeys(new Date().getTime()); - - await htmlChanges(); - - let newIssuesCount = await issuesHelper.getIssues().count(); - - expect(newIssuesCount).to.be.equal(0); - - await utils.common.takeScreenshot('issues', 'empty-issues'); - await utils.common.clear(filterInput); - }); - - it('save custom filter', async function() { - issuesHelper.filtersCats().get(1).$('a').click(); - issuesHelper.selectFilter(0); - - await browser.waitForAngular(); - - await issuesHelper.saveFilter('custom'); - - let customFilters = await issuesHelper.getCustomFilters().count(); - - expect(customFilters).to.be.equal(1); - - await issuesHelper.removeFilters(); - issuesHelper.backToFilters(); - }); - - it('apply custom filter', async function() { - let table = issuesHelper.getTable(); - let htmlChanges = await utils.common.outerHtmlChanges(table); - - issuesHelper.filtersCats().get(7).$('a').click(); - - issuesHelper.selectFilter(0); - - await htmlChanges(); - - await issuesHelper.removeFilters(); - }); - - it('remove custom filter', async function() { - await issuesHelper.removeCustomFilters(); - - let customFilterCount = await issuesHelper.getCustomFilters().count(); - - expect(customFilterCount).to.be.equal(0); - }); - }); + describe('issues filters', sharedFilters.bind(this, 'issues', () => { + return issuesHelper.getIssues().count(); + })); }); diff --git a/e2e/suites/kanban.e2e.js b/e2e/suites/kanban.e2e.js index 3f4fd80d..d92c80cc 100644 --- a/e2e/suites/kanban.e2e.js +++ b/e2e/suites/kanban.e2e.js @@ -2,6 +2,7 @@ var utils = require('../utils'); var kanbanHelper = require('../helpers').kanban; var backlogHelper = require('../helpers').backlog; var commonHelper = require('../helpers').common; +var filterHelper = require('../helpers/filters-helper'); var chai = require('chai'); var chaiAsPromised = require('chai-as-promised'); @@ -18,6 +19,24 @@ describe('kanban', function() { utils.common.takeScreenshot('kanban', 'kanban'); }); + it('zoom', async function() { + kanbanHelper.zoom(1); + await browser.sleep(1000); + utils.common.takeScreenshot('kanban', 'zoom1'); + + kanbanHelper.zoom(2); + await browser.sleep(1000); + utils.common.takeScreenshot('kanban', 'zoom2'); + + kanbanHelper.zoom(3); + await browser.sleep(1000); + utils.common.takeScreenshot('kanban', 'zoom3'); + + kanbanHelper.zoom(4); + await browser.sleep(1000); + utils.common.takeScreenshot('kanban', 'zoom4'); + }); + describe('create us', function() { let createUSLightbox = null; let formFields = {}; @@ -148,7 +167,6 @@ describe('kanban', function() { await utils.lightbox.close(createUSLightbox.el); let ussTitles = await kanbanHelper.getColumnUssTitles(0); - let findSubject = ussTitles.indexOf(formFields.subject) !== -1; expect(findSubject).to.be.true; @@ -297,8 +315,12 @@ describe('kanban', function() { await lightbox.waitClose(); - let usAssignedTo = await kanbanHelper.getBoxUss(0).get(0).$('.task-assigned').getText(); + let usAssignedTo = await kanbanHelper.getBoxUss(0).get(0).$('.card-owner-name').getText(); expect(assgnedToName).to.be.equal(usAssignedTo); }); + + describe('kanban filters', sharedFilters.bind(this, 'kanban', () => { + return kanbanHelper.getUss().count(); + })); }); diff --git a/e2e/suites/tasks/taskboard.e2e.js b/e2e/suites/tasks/taskboard.e2e.js index 3113b4a0..8dd56f08 100644 --- a/e2e/suites/tasks/taskboard.e2e.js +++ b/e2e/suites/tasks/taskboard.e2e.js @@ -2,6 +2,8 @@ var utils = require('../../utils'); var backlogHelper = require('../../helpers').backlog; var taskboardHelper = require('../../helpers').taskboard; var commonHelper = require('../../helpers').common; +var filterHelper = require('../../helpers/filters-helper'); +var sharedFilters = require('../../shared/filters'); var chai = require('chai'); var chaiAsPromised = require('chai-as-promised'); @@ -21,6 +23,24 @@ describe('taskboard', function() { utils.common.takeScreenshot('taskboard', 'taskboard'); }); + it('zoom', async function() { + taskboardHelper.zoom(1); + await browser.sleep(1000); + utils.common.takeScreenshot('taskboard', 'zoom1'); + + taskboardHelper.zoom(2); + await browser.sleep(1000); + utils.common.takeScreenshot('taskboard', 'zoom2'); + + taskboardHelper.zoom(3); + await browser.sleep(1000); + utils.common.takeScreenshot('taskboard', 'zoom3'); + + taskboardHelper.zoom(4); + await browser.sleep(1000); + utils.common.takeScreenshot('taskboard', 'zoom4'); + }); + describe('create task', function() { let createTaskLightbox = null; let formFields = {}; @@ -65,7 +85,7 @@ describe('taskboard', function() { let tasks = taskboardHelper.getBoxTasks(0, 0); - let tasksSubject = await $$('.task-name').getText(); + let tasksSubject = await $$('.e2e-title').getText(); let findSubject = tasksSubject.indexOf(formFields.subject) !== -1; @@ -111,7 +131,7 @@ describe('taskboard', function() { let tasks = taskboardHelper.getBoxTasks(0, 0); - let tasksSubject = await $$('.task-name').getText(); + let tasksSubject = await $$('.e2e-title').getText(); let findSubject = tasksSubject.indexOf(formFields.subject) !== 1; @@ -296,4 +316,8 @@ describe('taskboard', function() { expect(open).to.be.false; }); }); + + describe('taskboard filters', sharedFilters.bind(this, 'taskboard', () => { + return taskboardHelper.getTasks().count(); + })); }); diff --git a/gulpfile.js b/gulpfile.js index 7c5b788f..b69e5c92 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -291,7 +291,11 @@ gulp.task("css-lint-app", function() { return gulp.src(cssFiles) .pipe(gulpif(!isDeploy, cache(csslint("csslintrc.json"), { success: function(csslintFile) { - return csslintFile.csslint.success; + if (csslintFile.csslint) { + return csslintFile.csslint.success; + } else { + return false; + } }, value: function(csslintFile) { return {