From 3d858cf82aedc6006a987b4cd0b28dd39fd39598 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 26 Aug 2016 12:11:12 +0200 Subject: [PATCH] Epic detail --- app/coffee/app.coffee | 14 +- app/coffee/modules/common/filters.coffee | 26 ++ app/coffee/modules/epics.coffee | 2 +- app/coffee/modules/epics/detail.coffee | 337 ++++++++++++++++++ app/coffee/modules/resources.coffee | 8 + app/coffee/modules/resources/epics.coffee | 26 +- app/locales/taiga/locale-en.json | 21 ++ .../belong-to-epics/belong-to-epics-text.jade | 2 +- .../belong-to-epics.directive.coffee | 3 - .../detail/header/detail-header.jade | 2 - .../watch-button.controller.coffee | 3 +- .../epics/dashboard/epic-row/epic-row.jade | 2 +- .../related-userstories-controller.coffee | 33 ++ ...lated-userstories-create.controller.coffee | 92 +++++ ...-userstories-create.controller.spec.coffee | 185 ++++++++++ ...elated-userstories-create.directive.coffee | 79 ++++ .../related-userstories-create.jade | 153 ++++++++ .../related-userstories-create.scss | 78 ++++ ...related-userstories.controller.spec.coffee | 66 ++++ .../related-userstories.directive.coffee | 37 ++ .../related-userstories.jade | 23 ++ .../related-userstories.scss | 147 ++++++++ .../related-userstory-row.controller.coffee | 63 ++++ ...lated-userstory-row.controller.spec.coffee | 169 +++++++++ .../related-userstory-row.directive.coffee | 42 +++ .../related-userstory-row.jade | 44 +++ .../resources/epics-resource.service.coffee | 25 ++ .../userstories-resource.service.coffee | 16 + app/partials/epic/epic-detail.jade | 127 +++++++ app/styles/modules/common/wizard.scss | 1 - conf.e2e.js | 82 ++--- e2e/helpers/detail-helper.js | 42 ++- e2e/helpers/epic-detail-helper.js | 76 ++++ ...cs-helper.js => epics-dashboard-helper.js} | 13 +- e2e/helpers/index.js | 2 + e2e/helpers/us-detail-helper.js | 39 -- e2e/shared/detail.js | 38 +- .../admin/attributes/custom-fields.e2e.js | 63 +++- e2e/suites/admin/members.e2e.js | 2 +- e2e/suites/epics/epic-dashboard.e2e.js | 38 +- e2e/suites/epics/epic-detail.e2e.js | 100 ++++++ .../user-stories/user-story-detail.e2e.js | 30 +- e2e/utils/nav.js | 14 + gulpfile.js | 1 + run-e2e.js | 1 + 45 files changed, 2214 insertions(+), 153 deletions(-) create mode 100644 app/coffee/modules/epics/detail.coffee create mode 100644 app/modules/epics/related-userstories/related-userstories-controller.coffee create mode 100644 app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.controller.coffee create mode 100644 app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.controller.spec.coffee create mode 100644 app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.directive.coffee create mode 100644 app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.jade create mode 100644 app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.scss create mode 100644 app/modules/epics/related-userstories/related-userstories.controller.spec.coffee create mode 100644 app/modules/epics/related-userstories/related-userstories.directive.coffee create mode 100644 app/modules/epics/related-userstories/related-userstories.jade create mode 100644 app/modules/epics/related-userstories/related-userstories.scss create mode 100644 app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.controller.coffee create mode 100644 app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.controller.spec.coffee create mode 100644 app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.directive.coffee create mode 100644 app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.jade create mode 100644 app/partials/epic/epic-detail.jade create mode 100644 e2e/helpers/epic-detail-helper.js rename e2e/helpers/{epics-helper.js => epics-dashboard-helper.js} (94%) create mode 100644 e2e/suites/epics/epic-detail.e2e.js diff --git a/app/coffee/app.coffee b/app/coffee/app.coffee index 97c3efbd..fb81877e 100644 --- a/app/coffee/app.coffee +++ b/app/coffee/app.coffee @@ -46,7 +46,7 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven $animateProvider.classNameFilter(/^(?:(?!ng-animate-disabled).)*$/) - # wait until the trasnlation is ready to resolve the page + # wait until the translation is ready to resolve the page originalWhen = $routeProvider.when $routeProvider.when = (path, route) -> @@ -162,6 +162,15 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven } ) + # Epics + $routeProvider.when("/project/:pslug/epic/:epicref", + { + templateUrl: "epic/epic-detail.html", + loader: true, + section: "epics" + } + ) + $routeProvider.when("/project/:pslug/backlog", { templateUrl: "backlog/backlog.html", @@ -793,6 +802,7 @@ modules = [ "taigaPlugins", "taigaIntegrations", "taigaComponents", + # new modules "taigaProfile", "taigaHome", @@ -801,7 +811,7 @@ modules = [ "taigaDiscover", "taigaHistory", "taigaWikiHistory", - 'taigaEpics', + "taigaEpics", # template cache "templates", diff --git a/app/coffee/modules/common/filters.coffee b/app/coffee/modules/common/filters.coffee index 7590b45c..17696c55 100644 --- a/app/coffee/modules/common/filters.coffee +++ b/app/coffee/modules/common/filters.coffee @@ -74,3 +74,29 @@ sizeFormat = => return @.taiga.sizeFormat module.filter("sizeFormat", sizeFormat) + + +toMutableFilter = -> + toMutable = (js) -> + return js.toJS() + + memoizedMutable = _.memoize(toMutable) + + return (input) -> + if input instanceof Immutable.List + return memoizedMutable(input) + + return input + +module.filter("toMutable", toMutableFilter) + + +byRefFilter = ($filterFilter)-> + return (userstories, filter) -> + if filter?.startsWith("#") + cleanRef= filter.substr(1) + return _.filter(userstories, (us) => String(us.ref).startsWith(cleanRef)) + + return $filterFilter(userstories, filter) + +module.filter("byRef", ["filterFilter", byRefFilter]) diff --git a/app/coffee/modules/epics.coffee b/app/coffee/modules/epics.coffee index 15941253..743e70d4 100644 --- a/app/coffee/modules/epics.coffee +++ b/app/coffee/modules/epics.coffee @@ -19,7 +19,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # -# File: modules/projects.coffee +# File: modules/epics.coffee ### module = angular.module("taigaEpics", []) diff --git a/app/coffee/modules/epics/detail.coffee b/app/coffee/modules/epics/detail.coffee new file mode 100644 index 00000000..f5a35849 --- /dev/null +++ b/app/coffee/modules/epics/detail.coffee @@ -0,0 +1,337 @@ +### +# 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/epics/detail.coffee +### + +taiga = @.taiga + +mixOf = @.taiga.mixOf +toString = @.taiga.toString +joinStr = @.taiga.joinStr +groupBy = @.taiga.groupBy +bindOnce = @.taiga.bindOnce +bindMethods = @.taiga.bindMethods + +module = angular.module("taigaEpics") + +############################################################################# +## Epic Detail Controller +############################################################################# + +class EpicDetailController extends mixOf(taiga.Controller, taiga.PageMixin) + @.$inject = [ + "$scope", + "$rootScope", + "$tgRepo", + "$tgConfirm", + "$tgResources", + "tgResources" + "$routeParams", + "$q", + "$tgLocation", + "$log", + "tgAppMetaService", + "$tgAnalytics", + "$tgNavUrls", + "$translate", + "$tgQueueModelTransformation", + "tgErrorHandlingService" + ] + + constructor: (@scope, @rootscope, @repo, @confirm, @rs, @rs2, @params, @q, @location, + @log, @appMetaService, @analytics, @navUrls, @translate, @modelTransform, @errorHandlingService) -> + bindMethods(@) + + @scope.epicRef = @params.epicref + @scope.sectionName = @translate.instant("EPIC.SECTION_NAME") + @.initializeEventHandlers() + + promise = @.loadInitialData() + + # On Success + promise.then => + @._setMeta() + @.initializeOnDeleteGoToUrl() + + # On Error + promise.then null, @.onInitialDataError.bind(@) + + _setMeta: -> + title = @translate.instant("EPIC.PAGE_TITLE", { + epicRef: "##{@scope.epic.ref}" + epicSubject: @scope.epic.subject + projectName: @scope.project.name + }) + description = @translate.instant("EPIC.PAGE_DESCRIPTION", { + epicStatus: @scope.statusById[@scope.epic.status]?.name or "--" + epicDescription: angular.element(@scope.epic.description_html or "").text() + }) + @appMetaService.setAll(title, description) + + initializeEventHandlers: -> + @scope.$on "attachment:create", => + @analytics.trackEvent("attachment", "create", "create attachment on epic", 1) + + @scope.$on "comment:new", => + @.loadEpic() + + @scope.$on "custom-attributes-values:edit", => + @rootscope.$broadcast("object:updated") + + initializeOnDeleteGoToUrl: -> + ctx = {project: @scope.project.slug} + @scope.onDeleteGoToUrl = @navUrls.resolve("project-epics", ctx) + + loadProject: -> + return @rs.projects.getBySlug(@params.pslug).then (project) => + @scope.projectId = project.id + @scope.project = project + @scope.immutableProject = Immutable.fromJS(project._attrs) + @scope.$emit('project:loaded', project) + @scope.statusList = project.epic_statuses + @scope.statusById = groupBy(project.epic_statuses, (x) -> x.id) + return project + + loadEpic: -> + return @rs.epics.getByRef(@scope.projectId, @params.epicref).then (epic) => + @scope.epic = epic + @scope.immutableEpic = Immutable.fromJS(epic._attrs) + @scope.epicId = epic.id + @scope.commentModel = epic + + @modelTransform.setObject(@scope, 'epic') + + if @scope.epic.neighbors.previous?.ref? + ctx = { + project: @scope.project.slug + ref: @scope.epic.neighbors.previous.ref + } + @scope.previousUrl = @navUrls.resolve("project-epics-detail", ctx) + + if @scope.epic.neighbors.next?.ref? + ctx = { + project: @scope.project.slug + ref: @scope.epic.neighbors.next.ref + } + @scope.nextUrl = @navUrls.resolve("project-epics-detail", ctx) + + loadUserstories: -> + return @rs2.userstories.listInEpic(@scope.epicId).then (data) => + @scope.userstories = data + + loadInitialData: -> + promise = @.loadProject() + return promise.then (project) => + @.fillUsersAndRoles(project.members, project.roles) + @.loadEpic().then(=> @.loadUserstories()) + + ### + # Note: This methods (onUpvote() and onDownvote()) are related to tg-vote-button. + # See app/modules/components/vote-button for more info + ### + onUpvote: -> + onSuccess = => + @.loadEpic() + @rootscope.$broadcast("object:updated") + onError = => + @confirm.notify("error") + + return @rs.epics.upvote(@scope.epicId).then(onSuccess, onError) + + onDownvote: -> + onSuccess = => + @.loadEpic() + @rootscope.$broadcast("object:updated") + onError = => + @confirm.notify("error") + + return @rs.epics.downvote(@scope.epicId).then(onSuccess, onError) + + ### + # Note: This methods (onWatch() and onUnwatch()) are related to tg-watch-button. + # See app/modules/components/watch-button for more info + ### + onWatch: -> + onSuccess = => + @.loadEpic() + @rootscope.$broadcast("object:updated") + onError = => + @confirm.notify("error") + + return @rs.epics.watch(@scope.epicId).then(onSuccess, onError) + + onUnwatch: -> + onSuccess = => + @.loadEpic() + @rootscope.$broadcast("object:updated") + onError = => + @confirm.notify("error") + + return @rs.epics.unwatch(@scope.epicId).then(onSuccess, onError) + + onSelectColor: (color) -> + onSelectColorSuccess = () => + @rootscope.$broadcast("object:updated") + @confirm.notify('success') + + onSelectColorError = () => + @confirm.notify('error') + + transform = @modelTransform.save (epic) -> + epic.color = color + return epic + + return transform.then(onSelectColorSuccess, onSelectColorError) + +module.controller("EpicDetailController", EpicDetailController) + + +############################################################################# +## Epic status display directive +############################################################################# + +EpicStatusDisplayDirective = ($template, $compile) -> + # Display if an epic is open or closed and its status. + # + # Example: + # tg-epic-status-display(ng-model="epic") + # + # Requirements: + # - Epic object (ng-model) + # - scope.statusById object + + template = $template.get("common/components/status-display.html", true) + + link = ($scope, $el, $attrs) -> + render = (epic) -> + status = $scope.statusById[epic.status] + + html = template({ + is_closed: status.is_closed + status: status + }) + + html = $compile(html)($scope) + $el.html(html) + + $scope.$watch $attrs.ngModel, (epic) -> + render(epic) if epic? + + $scope.$on "$destroy", -> + $el.off() + + return { + link: link + restrict: "EA" + require: "ngModel" + } + +module.directive("tgEpicStatusDisplay", ["$tgTemplate", "$compile", EpicStatusDisplayDirective]) + + +############################################################################# +## Epic status button directive +############################################################################# + +EpicStatusButtonDirective = ($rootScope, $repo, $confirm, $loading, $modelTransform, $compile, $translate, $template) -> + # Display the status of epic and you can edit it. + # + # Example: + # tg-epic-status-button(ng-model="epic") + # + # Requirements: + # - Epic object (ng-model) + # - scope.statusById object + # - $scope.project.my_permissions + + template = $template.get("common/components/status-button.html", true) + + link = ($scope, $el, $attrs, $model) -> + isEditable = -> + return $scope.project.my_permissions.indexOf("modify_epic") != -1 + + render = (epic) => + status = $scope.statusById[epic.status] + + html = $compile(template({ + status: status + statuses: $scope.statusList + editable: isEditable() + }))($scope) + + $el.html(html) + + save = (status) -> + currentLoading = $loading() + .target($el) + .start() + + transform = $modelTransform.save (epic) -> + epic.status = status + + return epic + + onSuccess = -> + $rootScope.$broadcast("object:updated") + currentLoading.finish() + + onError = -> + $confirm.notify("error") + currentLoading.finish() + + transform.then(onSuccess, onError) + + $el.on "click", ".js-edit-status", (event) -> + event.preventDefault() + event.stopPropagation() + return if not isEditable() + + $el.find(".pop-status").popover().open() + + $el.on "click", ".status", (event) -> + event.preventDefault() + event.stopPropagation() + return if not isEditable() + + target = angular.element(event.currentTarget) + + $.fn.popover().closeAll() + + save(target.data("status-id")) + + $scope.$watch () -> + return $model.$modelValue?.status + , () -> + epic = $model.$modelValue + render(epic) if epic + + $scope.$on "$destroy", -> + $el.off() + + return { + link: link + restrict: "EA" + require: "ngModel" + } + +module.directive("tgEpicStatusButton", ["$rootScope", "$tgRepo", "$tgConfirm", "$tgLoading", "$tgQueueModelTransformation", + "$compile", "$translate", "$tgTemplate", EpicStatusButtonDirective]) diff --git a/app/coffee/modules/resources.coffee b/app/coffee/modules/resources.coffee index 9085f7da..20b9fa9a 100644 --- a/app/coffee/modules/resources.coffee +++ b/app/coffee/modules/resources.coffee @@ -95,6 +95,12 @@ urls = { # Epics "epics": "/epics" + "epic-upvote": "/epics/%s/upvote" + "epic-downvote": "/epics/%s/downvote" + "epic-watch": "/epics/%s/watch" + "epic-unwatch": "/epics/%s/unwatch" + "epic-related-userstories": "/epics/%s/related_userstories" + "epic-related-userstories-bulk-create": "/epics/%s/related_userstories/bulk_create" # User stories "userstories": "/userstories" @@ -134,12 +140,14 @@ urls = { "wiki-links": "/wiki-links" # History + "history/epic": "/history/epic" "history/us": "/history/userstory" "history/issue": "/history/issue" "history/task": "/history/task" "history/wiki": "/history/wiki/%s" # Attachments + "attachments/epic": "/epics/attachments" "attachments/us": "/userstories/attachments" "attachments/issue": "/issues/attachments" "attachments/task": "/tasks/attachments" diff --git a/app/coffee/modules/resources/epics.coffee b/app/coffee/modules/resources/epics.coffee index 9793f485..480395ce 100644 --- a/app/coffee/modules/resources/epics.coffee +++ b/app/coffee/modules/resources/epics.coffee @@ -28,10 +28,16 @@ taiga = @.taiga generateHash = taiga.generateHash -resourceProvider = ($repo, $storage) -> +resourceProvider = ($repo, $http, $urls, $storage) -> service = {} hashSuffix = "epics-queryparams" + service.getByRef = (projectId, ref) -> + params = service.getQueryParams(projectId) + params.project = projectId + params.ref = ref + return $repo.queryOne("epics", "by_ref", params) + service.listValues = (projectId, type) -> params = {"project": projectId} service.storeQueryParams(projectId, params) @@ -47,9 +53,25 @@ resourceProvider = ($repo, $storage) -> hash = generateHash([projectId, ns]) return $storage.get(hash) or {} + service.upvote = (epicId) -> + url = $urls.resolve("epic-upvote", epicId) + return $http.post(url) + + service.downvote = (epicId) -> + url = $urls.resolve("epic-downvote", epicId) + return $http.post(url) + + service.watch = (epicId) -> + url = $urls.resolve("epic-watch", epicId) + return $http.post(url) + + service.unwatch = (epicId) -> + url = $urls.resolve("epic-unwatch", epicId) + return $http.post(url) + return (instance) -> instance.epics = service module = angular.module("taigaResources") -module.factory("$tgEpicsResourcesProvider", ["$tgRepo", "$tgStorage", resourceProvider]) +module.factory("$tgEpicsResourcesProvider", ["$tgRepo","$tgHttp", "$tgUrls", "$tgStorage", resourceProvider]) diff --git a/app/locales/taiga/locale-en.json b/app/locales/taiga/locale-en.json index 3403ff03..cd73c545 100644 --- a/app/locales/taiga/locale-en.json +++ b/app/locales/taiga/locale-en.json @@ -47,6 +47,7 @@ "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", + "RELATED_USERSTORIES": "Related user stories", "CARD": { "ASSIGN_TO": "Assign To", "EDIT": "Edit card" @@ -1061,6 +1062,26 @@ "BUTTON": "Ask this project member to become the new project owner" } }, + "EPIC": { + "PAGE_TITLE": "{{epicSubject}} - Epic {{epicRef}} - {{projectName}}", + "PAGE_DESCRIPTION": "Status: {{epicStatus }}. Description: {{epicDescription}}", + "SECTION_NAME": "Epic", + "TITLE_LIGHTBOX_DELETE_RELATED_USERSTORY": "Delete related userstory...", + "MSG_LIGHTBOX_DELETE_RELATED_USERSTORY": "the related userstory '{{subject}}'", + "ERROR_DELETE_RELATED_USERSTORY": "We have not been able to delete: {{errorMessage}}", + "CREATE_RELATED_USERSTORIES": "Create a relationship with a user story", + "RELATED_WITH": "Related with", + "NEW_USERSTORY": "New user story", + "EXISTING_USERSTORY": "Existing user story", + "CHOOSE_PROJECT_FOR_CREATION": "Whats' the project?", + "SUBJECT": "Subject", + "SUBJECT_BULK_MODE": "Subject (bulk insert)", + "CHOOSE_PROJECT_FROM": "What's the project?", + "CHOOSE_USERSTORY": "What's the user story?", + "FILTER_USERSTORIES": "Filter user stories", + "LIGHTBOX_TITLE_BLOKING_EPIC": "Blocking epic", + "ACTION_DELETE": "Delete epic" + }, "US": { "PAGE_TITLE": "{{userStorySubject}} - User Story {{userStoryRef}} - {{projectName}}", "PAGE_DESCRIPTION": "Status: {{userStoryStatus }}. Completed {{userStoryProgressPercentage}}% ({{userStoryClosedTasks}} of {{userStoryTotalTasks}} tasks closed). Points: {{userStoryPoints}}. Description: {{userStoryDescription}}", diff --git a/app/modules/components/belong-to-epics/belong-to-epics-text.jade b/app/modules/components/belong-to-epics/belong-to-epics-text.jade index f8b935d0..db9614b9 100644 --- a/app/modules/components/belong-to-epics/belong-to-epics-text.jade +++ b/app/modules/components/belong-to-epics/belong-to-epics-text.jade @@ -6,5 +6,5 @@ span.belong-to-epic-text-wrapper(tg-repeat="epic in epics track by epic.get('id' ) a.belong-to-epic-text( href="" - tg-nav="project-epics-detail:project=vm.project.get('slug')" + tg-nav="project-epics-detail:project=epic.getIn(['project', 'slug']),ref=epic.get('ref')" ) #{hash}{{epic.get('id')}} {{epic.get('subject')}} diff --git a/app/modules/components/belong-to-epics/belong-to-epics.directive.coffee b/app/modules/components/belong-to-epics/belong-to-epics.directive.coffee index 1e7d8fa7..08d4ac43 100644 --- a/app/modules/components/belong-to-epics/belong-to-epics.directive.coffee +++ b/app/modules/components/belong-to-epics/belong-to-epics.directive.coffee @@ -25,9 +25,6 @@ BelongToEpicsDirective = () -> if scope.epics && !scope.epics.isIterable scope.epics = Immutable.fromJS(scope.epics) - if scope.project && !scope.project.isIterable - scope.project = Immutable.fromJS(scope.project) - scope.getTemplateUrl = () -> if attrs.format return "components/belong-to-epics/belong-to-epics-" + attrs.format + ".html" diff --git a/app/modules/components/detail/header/detail-header.jade b/app/modules/components/detail/header/detail-header.jade index 82615b20..317065c0 100644 --- a/app/modules/components/detail/header/detail-header.jade +++ b/app/modules/components/detail/header/detail-header.jade @@ -41,7 +41,6 @@ ng-if="::vm.item.epics" epics="::vm.item.epics" format="text" - project="project" ) //- Task belongs to US @@ -60,7 +59,6 @@ ng-if="::vm.item.user_story_extra_info.epics" epics="::vm.item.user_story_extra_info.epics" format="pill" - project="vm.project" ) //- User Stories generated from issue diff --git a/app/modules/components/watch-button/watch-button.controller.coffee b/app/modules/components/watch-button/watch-button.controller.coffee index 99514424..e7cbae9c 100644 --- a/app/modules/components/watch-button/watch-button.controller.coffee +++ b/app/modules/components/watch-button/watch-button.controller.coffee @@ -45,7 +45,8 @@ class WatchButtonController perms = { userstories: 'modify_us', issues: 'modify_issue', - tasks: 'modify_task' + tasks: 'modify_task', + epics: 'modify_epic' } return perms[name] diff --git a/app/modules/epics/dashboard/epic-row/epic-row.jade b/app/modules/epics/dashboard/epic-row/epic-row.jade index 452d928a..2dc410b5 100644 --- a/app/modules/epics/dashboard/epic-row/epic-row.jade +++ b/app/modules/epics/dashboard/epic-row/epic-row.jade @@ -15,7 +15,7 @@ .name(ng-if="vm.column.name") - var hash = "#"; a( - tg-nav="project-epics-detail:project=vm.project.get('slug')" + tg-nav="project-epics-detail:project=vm.project.slug,ref=vm.epic.get('ref')" ng-attr-title="{{::vm.epic.get('subject')}}" ) #{hash}{{::vm.epic.get('ref')}} {{::vm.epic.get('subject')}} span.epic-pill( diff --git a/app/modules/epics/related-userstories/related-userstories-controller.coffee b/app/modules/epics/related-userstories/related-userstories-controller.coffee new file mode 100644 index 00000000..8042fa8f --- /dev/null +++ b/app/modules/epics/related-userstories/related-userstories-controller.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: related-userstories.controller.coffee +### + +module = angular.module("taigaEpics") + +class RelatedUserStoriesController + @.$inject = ["tgResources"] + + constructor: (@rs) -> + @.sectionName = "Epics" + @.showCreateRelatedUserstoriesLightbox = false + + loadRelatedUserstories: () -> + @rs.userstories.listInEpic(@.epic.get('id')).then (data) => + @.userstories = data + +module.controller("RelatedUserStoriesCtrl", RelatedUserStoriesController) diff --git a/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.controller.coffee b/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.controller.coffee new file mode 100644 index 00000000..8b51a2c2 --- /dev/null +++ b/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.controller.coffee @@ -0,0 +1,92 @@ +### +# 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: related-userstory-create.controller.coffee +### + +module = angular.module("taigaEpics") + +class RelatedUserstoriesCreateController + @.$inject = [ + "tgCurrentUserService", + "tgResources", + "$tgConfirm", + "$tgAnalytics" + ] + + constructor: (@currentUserService, @rs, @confirm, @analytics) -> + @.projects = @currentUserService.projects.get("all") + @.projectUserstories = Immutable.List() + @.loading = false + + selectProject: (selectedProjectId, onSelectedProject) -> + @rs.userstories.listAllInProject(selectedProjectId).then (data) => + excludeIds = @.epicUserstories.map((us) -> us.get('id')) + filteredData = data.filter((us) -> excludeIds.indexOf(us.get('id')) == -1) + @.projectUserstories = filteredData + if onSelectedProject + onSelectedProject() + + saveRelatedUserStory: (selectedUserstoryId, onSavedRelatedUserstory) -> + # This method assumes the following methods are binded to the controller: + # - validateExistingUserstoryForm + # - setExistingUserstoryFormErrors + # - loadRelatedUserstories + return if not @.validateExistingUserstoryForm() + + @.loading = true + + onError = (data) => + @.loading = false + @confirm.notify("error") + @.setExistingUserstoryFormErrors(data) + + onSuccess = () => + @analytics.trackEvent("epic related user story", "create", "create related user story on epic", 1) + @.loading = false + if onSavedRelatedUserstory + onSavedRelatedUserstory() + @.loadRelatedUserstories() + + epicId = @.epic.get('id') + @rs.epics.addRelatedUserstory(epicId, selectedUserstoryId).then(onSuccess, onError) + + bulkCreateRelatedUserStories: (selectedProjectId, userstoriesText, onCreatedRelatedUserstory) -> + # This method assumes the following methods are binded to the controller: + # - validateNewUserstoryForm + # - setNewUserstoryFormErrors + # - loadRelatedUserstories + return if not @.validateNewUserstoryForm() + + @.loading = true + + onError = (data) => + @.loading = false + @confirm.notify("error") + @.setNewUserstoryFormErrors(data) + + onSuccess = () => + @analytics.trackEvent("epic related user story", "create", "create related user story on epic", 1) + @.loading = false + if onCreatedRelatedUserstory + onCreatedRelatedUserstory() + @.loadRelatedUserstories() + + epicId = @.epic.get('id') + @rs.epics.bulkCreateRelatedUserStories(epicId, selectedProjectId, userstoriesText).then(onSuccess, onError) + + +module.controller("RelatedUserstoriesCreateCtrl", RelatedUserstoriesCreateController) diff --git a/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.controller.spec.coffee b/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.controller.spec.coffee new file mode 100644 index 00000000..f3bc84b1 --- /dev/null +++ b/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.controller.spec.coffee @@ -0,0 +1,185 @@ +### +# 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: related-userstories-create.controller.spec.coffee +### + +describe "RelatedUserstoriesCreate", -> + RelatedUserstoriesCreateCtrl = null + provide = null + controller = null + mocks = {} + + _mockTgCurrentUserService = () -> + mocks.tgCurrentUserService = { + projects: { + get: sinon.stub() + } + } + + provide.value "tgCurrentUserService", mocks.tgCurrentUserService + + _mockTgConfirm = () -> + mocks.tgConfirm = { + askOnDelete: sinon.stub() + notify: sinon.stub() + } + + provide.value "$tgConfirm", mocks.tgConfirm + + + _mockTgResources = () -> + mocks.tgResources = { + userstories: { + listAllInProject: sinon.stub() + } + epics: { + deleteRelatedUserstory: sinon.stub() + addRelatedUserstory: sinon.stub() + bulkCreateRelatedUserStories: sinon.stub() + } + } + + provide.value "tgResources", mocks.tgResources + + _mockTgAnalytics = () -> + mocks.tgAnalytics = { + trackEvent: sinon.stub() + } + + provide.value "$tgAnalytics", mocks.tgAnalytics + + _mocks = () -> + module ($provide) -> + provide = $provide + _mockTgCurrentUserService() + _mockTgConfirm() + _mockTgResources() + _mockTgAnalytics() + return null + + beforeEach -> + module "taigaEpics" + + _mocks() + + inject ($controller) -> + controller = $controller + + RelatedUserstoriesCreateCtrl = controller "RelatedUserstoriesCreateCtrl" + + it "select project", (done) -> + # This test tries to reproduce a project containing userstories 11 and 12 where 11 + # is yet related to the epic + RelatedUserstoriesCreateCtrl.epicUserstories = Immutable.fromJS([ + { + id: 11 + } + ]) + + onSelectedProjectCallback = sinon.stub() + userstories = Immutable.fromJS([ + { + id: 11 + }, + { + + id: 12 + } + ]) + filteredUserstories = Immutable.fromJS([ + { + + id: 12 + } + ]) + + promise = mocks.tgResources.userstories.listAllInProject.withArgs(1).promise().resolve(userstories) + RelatedUserstoriesCreateCtrl.selectProject(1, onSelectedProjectCallback).then () -> + expect(RelatedUserstoriesCreateCtrl.projectUserstories.toJS()).to.eql(filteredUserstories.toJS()) + done() + + it "save related user story success", (done) -> + RelatedUserstoriesCreateCtrl.validateExistingUserstoryForm = sinon.stub() + RelatedUserstoriesCreateCtrl.validateExistingUserstoryForm.returns(true) + onSavedRelatedUserstoryCallback = sinon.stub() + onSavedRelatedUserstoryCallback.returns(true) + RelatedUserstoriesCreateCtrl.loadRelatedUserstories = sinon.stub() + RelatedUserstoriesCreateCtrl.epic = Immutable.fromJS({ + id: 1 + }) + promise = mocks.tgResources.epics.addRelatedUserstory.withArgs(1, 11).promise().resolve(true) + RelatedUserstoriesCreateCtrl.saveRelatedUserStory(11, onSavedRelatedUserstoryCallback).then () -> + expect(RelatedUserstoriesCreateCtrl.validateExistingUserstoryForm).have.been.calledOnce + expect(onSavedRelatedUserstoryCallback).have.been.calledOnce + expect(mocks.tgResources.epics.addRelatedUserstory).have.been.calledWith(1, 11) + expect(mocks.tgAnalytics.trackEvent).have.been.calledWith("epic related user story", "create", "create related user story on epic", 1) + expect(RelatedUserstoriesCreateCtrl.loadRelatedUserstories).have.been.calledOnce + done() + + it "save related user story error", (done) -> + RelatedUserstoriesCreateCtrl.validateExistingUserstoryForm = sinon.stub() + RelatedUserstoriesCreateCtrl.validateExistingUserstoryForm.returns(true) + onSavedRelatedUserstoryCallback = sinon.stub() + RelatedUserstoriesCreateCtrl.setExistingUserstoryFormErrors = sinon.stub() + RelatedUserstoriesCreateCtrl.setExistingUserstoryFormErrors.returns({}) + RelatedUserstoriesCreateCtrl.epic = Immutable.fromJS({ + id: 1 + }) + promise = mocks.tgResources.epics.addRelatedUserstory.withArgs(1, 11).promise().reject(new Error("error")) + RelatedUserstoriesCreateCtrl.saveRelatedUserStory(11, onSavedRelatedUserstoryCallback).then () -> + expect(RelatedUserstoriesCreateCtrl.validateExistingUserstoryForm).have.been.calledOnce + expect(onSavedRelatedUserstoryCallback).to.not.have.been.called + expect(mocks.tgResources.epics.addRelatedUserstory).have.been.calledWith(1, 11) + expect(mocks.tgConfirm.notify).have.been.calledWith("error") + expect(RelatedUserstoriesCreateCtrl.setExistingUserstoryFormErrors).have.been.calledOnce + done() + + it "bulk create related user stories success", (done) -> + RelatedUserstoriesCreateCtrl.validateNewUserstoryForm = sinon.stub() + RelatedUserstoriesCreateCtrl.validateNewUserstoryForm.returns(true) + onCreatedRelatedUserstoryCallback = sinon.stub() + onCreatedRelatedUserstoryCallback.returns(true) + RelatedUserstoriesCreateCtrl.loadRelatedUserstories = sinon.stub() + RelatedUserstoriesCreateCtrl.epic = Immutable.fromJS({ + id: 1 + }) + promise = mocks.tgResources.epics.bulkCreateRelatedUserStories.withArgs(1, 22, 'a\nb').promise().resolve(true) + RelatedUserstoriesCreateCtrl.bulkCreateRelatedUserStories(22, 'a\nb', onCreatedRelatedUserstoryCallback).then () -> + expect(RelatedUserstoriesCreateCtrl.validateNewUserstoryForm).have.been.calledOnce + expect(onCreatedRelatedUserstoryCallback).have.been.calledOnce + expect(mocks.tgResources.epics.bulkCreateRelatedUserStories).have.been.calledWith(1, 22, 'a\nb') + expect(mocks.tgAnalytics.trackEvent).have.been.calledWith("epic related user story", "create", "create related user story on epic", 1) + expect(RelatedUserstoriesCreateCtrl.loadRelatedUserstories).have.been.calledOnce + done() + + it "bulk create related user stories error", (done) -> + RelatedUserstoriesCreateCtrl.validateNewUserstoryForm = sinon.stub() + RelatedUserstoriesCreateCtrl.validateNewUserstoryForm.returns(true) + onCreatedRelatedUserstoryCallback = sinon.stub() + RelatedUserstoriesCreateCtrl.setNewUserstoryFormErrors = sinon.stub() + RelatedUserstoriesCreateCtrl.setNewUserstoryFormErrors.returns({}) + RelatedUserstoriesCreateCtrl.epic = Immutable.fromJS({ + id: 1 + }) + promise = mocks.tgResources.epics.bulkCreateRelatedUserStories.withArgs(1, 22, 'a\nb').promise().reject(new Error("error")) + RelatedUserstoriesCreateCtrl.bulkCreateRelatedUserStories(22, 'a\nb', onCreatedRelatedUserstoryCallback).then () -> + expect(RelatedUserstoriesCreateCtrl.validateNewUserstoryForm).have.been.calledOnce + expect(onCreatedRelatedUserstoryCallback).to.not.have.been.called + expect(mocks.tgResources.epics.bulkCreateRelatedUserStories).have.been.calledWith(1, 22, 'a\nb') + expect(mocks.tgConfirm.notify).have.been.calledWith("error") + expect(RelatedUserstoriesCreateCtrl.setNewUserstoryFormErrors).have.been.calledOnce + done() diff --git a/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.directive.coffee b/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.directive.coffee new file mode 100644 index 00000000..9ecd4a03 --- /dev/null +++ b/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.directive.coffee @@ -0,0 +1,79 @@ +### +# 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: related-userstory-create.directive.coffee +### + +module = angular.module('taigaEpics') + +RelatedUserstoriesCreateDirective = (@lightboxService) -> + link = (scope, el, attrs, ctrl) -> + newUserstoryForm = el.find(".new-user-story-form").checksley() + existingUserstoryForm = el.find(".existing-user-story-form").checksley() + + ctrl.validateNewUserstoryForm = => + return newUserstoryForm.validate() + + ctrl.setNewUserstoryFormErrors = (errors) => + newUserstoryForm.setErrors(errors) + + ctrl.validateExistingUserstoryForm = => + return existingUserstoryForm.validate() + + ctrl.setExistingUserstoryFormErrors = (errors) => + existingUserstoryForm.setErrors(errors) + + scope.showLightbox = (selectedProjectId) -> + scope.selectProject(selectedProjectId).then () => + lightboxService.open(el.find(".lightbox-create-related-user-stories")) + + scope.closeLightbox = () -> + scope.selectedUserstory = null + scope.searchUserstory = "" + scope.relatedUserstoriesText = "" + lightboxService.close(el.find(".lightbox-create-related-user-stories")) + + scope.$watch 'vm.project', (project) -> + if project? + scope.selectedProject = project.get('id') + + scope.selectProject = (selectedProjectId) -> + scope.selectedUserstory = null + scope.searchUserstory = "" + ctrl.selectProject(selectedProjectId) + + scope.onUpdateSearchUserstory = () -> + scope.selectedUserstory = null + + return { + link: link, + templateUrl:"epics/related-userstories/related-userstories-create/related-userstories-create.html", + controller: "RelatedUserstoriesCreateCtrl", + controllerAs: "vm", + bindToController: true, + scope: { + showCreateRelatedUserstoriesLightbox: "=" + project: "=" + epic: "=" + epicUserstories: "=" + loadRelatedUserstories:"&" + } + + } + +RelatedUserstoriesCreateDirective.$inject = ["lightboxService",] + +module.directive("tgRelatedUserstoriesCreate", RelatedUserstoriesCreateDirective) diff --git a/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.jade b/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.jade new file mode 100644 index 00000000..468acbbb --- /dev/null +++ b/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.jade @@ -0,0 +1,153 @@ +a.add-button.e2e-add-userstory-button( + href="" + ng-click="showLightbox(vm.project.get('id'))" +) + tg-svg(svg-icon="icon-add") + +div.lightbox.lightbox-create-related-user-stories + tg-lightbox-close + + div.form + h2.title(translate="EPIC.CREATE_RELATED_USERSTORIES") + + .related-with-selector-title + legend(translate="EPIC.RELATED_WITH") + + .related-with-selector + fieldset + input( + type="radio" + name="related-with-selector" + id="new-user-story" + value="new-user-story" + ng-model="relatedWithSelector" + ng-init="relatedWithSelector='new-user-story'" + ) + label.e2e-new-userstory-label(for="new-user-story") + span.name {{ 'EPIC.NEW_USERSTORY' | translate}} + + fieldset + input( + type="radio" + name="related-with-selector" + id="existing-user-story" + value="existing-user-story" + ng-model="relatedWithSelector" + ) + label.e2e-existing-user-story-label(for="existing-user-story") + span.name {{ 'EPIC.EXISTING_USERSTORY' | translate}} + + .project-selector-title + legend( + ng-if="relatedWithSelector=='new-user-story'" + translate="EPIC.CHOOSE_PROJECT_FOR_CREATION" + ) + + legend( + ng-if="relatedWithSelector=='existing-user-story'" + translate="EPIC.CHOOSE_PROJECT_FROM" + ) + + .project-selector() + select( + ng-model="selectedProject" + ng-change="selectProject(selectedProject)" + data-required="true" + required + ng-options="p.id as p.name for p in vm.projects | toMutable" + ) + + div(ng-show="relatedWithSelector=='new-user-story'") + .new-user-story-selector + .new-user-story-title + legend( + ng-show="creationMode=='single-new-user-story'" + translate="EPIC.SUBJECT" + ) + + legend( + ng-show="creationMode=='bulk-new-user-stories'" + translate="EPIC.SUBJECT_BULK_MODE" + ) + .new-user-story-options + fieldset + input( + type="radio" + name="new-user-story-selector" + id="single-new-user-story" + value="single-new-user-story" + ng-model="creationMode" + ng-init="creationMode='single-new-user-story'" + ) + label.e2e-single-creation-label(for="single-new-user-story") + tg-svg(svg-icon="icon-add") + + fieldset + input( + type="radio" + name="new-user-story-selector" + id="bulk-new-user-stories" + value="bulk-new-user-stories" + ng-model="creationMode" + ) + label.e2e-bulk-creation-label(for="bulk-new-user-stories") + tg-svg(svg-icon="icon-bulk") + + form.new-user-story-form + .single-creation(ng-show="creationMode=='single-new-user-story'") + input.e2e-new-userstory-input-text( + type="text" + ng-model="relatedUserstoriesText" + data-required="true" + ) + + .bulk-creation(ng-show="creationMode=='bulk-new-user-stories'") + textarea.e2e-new-userstories-input-textarea( + ng-model="relatedUserstoriesText" + data-required="true" + ) + + a.button-green.e2e-create-userstory-button( + href="" + ng-click="vm.bulkCreateRelatedUserStories(selectedProject, relatedUserstoriesText, closeLightbox)" + tg-loading="vm.loading" + ) + span( + translate="COMMON.SAVE" + ) + + .existing-user-story(ng-show="relatedWithSelector=='existing-user-story'") + .existing-user-story-title + legend(translate="EPIC.CHOOSE_USERSTORY") + + input.userstory.e2e-filter-userstories-input( + type="text" + placeholder="{{'EPIC.FILTER_USERSTORIES' | translate}}" + ng-model="searchUserstory" + ng-change="onUpdateSearchUserstory()" + ) + + form.existing-user-story-form + select.userstory.e2e-userstories-select( + size="5" + ng-model="selectedUserstory" + required + data-required="true" + ) + - var hash = "#"; + option.hidden( + value="" + ) + option( + ng-repeat="us in vm.projectUserstories | toMutable | byRef:searchUserstory track by us.id" + value="{{ ::us.id }}" + ) #{hash}{{::us.ref}} {{::us.subject}} + + a.button-green.e2e-select-related-userstory-button( + href="" + ng-click="vm.saveRelatedUserStory(selectedUserstory, closeLightbox)" + tg-loading="vm.loading" + ) + span( + translate="COMMON.SAVE" + ) diff --git a/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.scss b/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.scss new file mode 100644 index 00000000..82412585 --- /dev/null +++ b/app/modules/epics/related-userstories/related-userstories-create/related-userstories-create.scss @@ -0,0 +1,78 @@ +.lightbox-create-related-user-stories { + .related-with-selector-title, + .project-selector-title, + .new-user-story-title, + .existing-user-story-title { + display: flex; + justify-content: space-between; + margin-bottom: 1rem; + } + .related-with-selector, + .new-user-story-selector { + display: flex; + input { + display: none; + } + fieldset { + &:first-child { + margin-right: .5rem; + } + } + } + .project-selector, + .single-creation { + margin-bottom: 1rem; + } + input { + &:checked+label { + background: $primary-light; + color: $white; + transition: background .2s ease-in; + &:hover { + background: $primary-light; + } + } + +label { + background: rgba($whitish, .7); + cursor: pointer; + display: block; + padding: 2rem 1rem; + text-align: center; + transition: background .2s ease-in; + &:hover { + background: rgba($primary-light, .3); + transition: background .2s ease-in; + } + .icon { + fill: currentColor; + margin-top: .25rem; + vertical-align: text-top; + } + .name { + @include font-size(large); + text-transform: uppercase; + } + } + } + .new-user-story-selector { + display: flex; + justify-content: space-between; + .new-user-story-options { + display: flex; + } + fieldset { + width: auto; + } + label { + height: 1.5rem; + padding: 0; + width: 1.5rem; + } + } + + .existing-user-story { + .button-green { + margin-top: 1rem; + } + } +} diff --git a/app/modules/epics/related-userstories/related-userstories.controller.spec.coffee b/app/modules/epics/related-userstories/related-userstories.controller.spec.coffee new file mode 100644 index 00000000..9162c935 --- /dev/null +++ b/app/modules/epics/related-userstories/related-userstories.controller.spec.coffee @@ -0,0 +1,66 @@ +### +# 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: related-userstories.controller.spec.coffee +### + +describe "RelatedUserStories", -> + RelatedUserStoriesCtrl = null + provide = null + controller = null + mocks = {} + + _mockTgResources = () -> + mocks.tgResources = { + userstories: { + listInEpic: sinon.stub() + } + } + + provide.value "tgResources", mocks.tgResources + + _mocks = () -> + module ($provide) -> + provide = $provide + _mockTgResources() + + return null + + beforeEach -> + module "taigaEpics" + + _mocks() + + inject ($controller) -> + controller = $controller + + RelatedUserStoriesCtrl = controller "RelatedUserStoriesCtrl" + + it "load related userstories", (done) -> + userstories = Immutable.fromJS([ + { + id: 1 + } + ]) + + RelatedUserStoriesCtrl.epic = Immutable.fromJS({ + id: 66 + }) + + promise = mocks.tgResources.userstories.listInEpic.withArgs(66).promise().resolve(userstories) + RelatedUserStoriesCtrl.loadRelatedUserstories().then () -> + expect(RelatedUserStoriesCtrl.userstories).is.equal(userstories) + done() diff --git a/app/modules/epics/related-userstories/related-userstories.directive.coffee b/app/modules/epics/related-userstories/related-userstories.directive.coffee new file mode 100644 index 00000000..e3db9be8 --- /dev/null +++ b/app/modules/epics/related-userstories/related-userstories.directive.coffee @@ -0,0 +1,37 @@ +### +# 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: related-userstories.directive.coffee +### + +module = angular.module('taigaEpics') + +RelatedUserStoriesDirective = () -> + return { + templateUrl:"epics/related-userstories/related-userstories.html", + controller: "RelatedUserStoriesCtrl", + controllerAs: "vm", + bindToController: true, + scope: { + userstories: '=', + project: '=' + epic: '=' + } + } + +RelatedUserStoriesDirective.$inject = [] + +module.directive("tgRelatedUserstories", RelatedUserStoriesDirective) diff --git a/app/modules/epics/related-userstories/related-userstories.jade b/app/modules/epics/related-userstories/related-userstories.jade new file mode 100644 index 00000000..ecf642de --- /dev/null +++ b/app/modules/epics/related-userstories/related-userstories.jade @@ -0,0 +1,23 @@ +section.related-userstories + .related-userstories-header + span.related-userstories-title(translate="COMMON.RELATED_USERSTORIES") + tg-related-userstories-create( + tg-check-permission="modify_epic" + show-create-related-userstories-lightbox="vm.showCreateRelatedUserstoriesLightbox" + project="vm.project" + epic="vm.epic" + epic-userstories="vm.userstories" + load-related-userstories="vm.loadRelatedUserstories()" + ) + + .related-userstories-body + div(tg-repeat="us in vm.userstories track by us.get('id')") + tg-related-userstory-row.row( + ng-class="{closed: us.get('is_closed'), blocked: us.get('is_blocked')}" + userstory="us" + epic="vm.epic" + project="vm.project" + load-related-userstories="vm.loadRelatedUserstories()" + ) + + div(tg-related-userstories-create-form) diff --git a/app/modules/epics/related-userstories/related-userstories.scss b/app/modules/epics/related-userstories/related-userstories.scss new file mode 100644 index 00000000..62bc0b46 --- /dev/null +++ b/app/modules/epics/related-userstories/related-userstories.scss @@ -0,0 +1,147 @@ +.related-userstories { + margin-bottom: 2rem; + position: relative; +} + +.related-userstories-header { + align-content: center; + align-items: center; + background: $mass-white; + display: flex; + justify-content: space-between; + min-height: 36px; + .related-userstories-title { + @include font-size(medium); + @include font-type(bold); + margin-left: 1rem; + } + .add-button { + background: $grayer; + border: 0; + display: inline-block; + padding: .5rem; + transition: background .25s; + &:hover, + &.is-active { + background: $primary-light; + } + svg { + fill: $white; + height: 1.25rem; + margin-bottom: -.2rem; + width: 1.25rem; + } + } +} + +.related-userstories-body { + width: 100%; + .row { + @include font-size(small); + align-items: center; + border-bottom: 1px solid $whitish; + display: flex; + padding: .5rem 0 .5rem .5rem; + &:hover { + .userstory-settings { + opacity: 1; + transition: all .2s ease-in; + } + } + .userstory-name { + flex: 1; + } + .userstory-settings { + flex-shrink: 0; + width: 60px; + } + .status { + flex-shrink: 0; + width: 125px; + } + .assigned-to-column { + flex-shrink: 0; + width: 150px; + img { + flex-basis: 35px; + // width & height they are only required for IE + height: 35px; + width: 35px; + } + } + .project { + flex-basis: 100px; + img { + width: 40px; + } + } + } + + .userstory-name { + display: flex; + margin-right: 1rem; + + span { + margin-right: .25rem; + } + } + .status { + position: relative; + } + .closed { + border-left: 10px solid $whitish; + color: $whitish; + a, + svg { + fill: $whitish; + } + .userstory-name a { + color: $whitish; + text-decoration: line-through; + + } + } + .blocked { + background: rgba($red-light, .2); + border-left: 10px solid $red-light; + } + .userstory-settings { + align-items: center; + display: flex; + opacity: 0; + svg { + @include svg-size(1.1rem); + fill: $gray-light; + margin-right: .5rem; + transition: fill .2s ease-in; + &:hover { + fill: $gray; + } + } + a { + &:hover { + cursor: pointer; + } + } + } + .delete-userstory { + &:hover { + .icon-trash { + fill: $red-light; + } + } + } + .avatar { + align-items: center; + display: flex; + img { + flex-basis: 35px; + // width & height they are only required for IE + height: 35px; + width: 35px; + } + figcaption { + margin-left: .5rem; + } + } +} diff --git a/app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.controller.coffee b/app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.controller.coffee new file mode 100644 index 00000000..ef58ab9b --- /dev/null +++ b/app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.controller.coffee @@ -0,0 +1,63 @@ +### +# 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: reñated-userstory-row.controller.coffee +### + +module = angular.module("taigaEpics") + +class RelatedUserstoryRowController + @.$inject = [ + "tgAvatarService", + "$translate", + "$tgConfirm", + "tgResources" + ] + + constructor: (@avatarService, @translate, @confirm, @rs) -> + + setAvatarData: () -> + member = @.userstory.get('assigned_to_extra_info') + @.avatar = @avatarService.getAvatar(member) + + getAssignedToFullNameDisplay: () -> + if @.userstory.get('assigned_to') + return @.userstory.getIn(['assigned_to_extra_info', 'full_name_display']) + + return @translate.instant("COMMON.ASSIGNED_TO.NOT_ASSIGNED") + + onDeleteRelatedUserstory: () -> + title = @translate.instant('EPIC.TITLE_LIGHTBOX_DELETE_RELATED_USERSTORY') + message = @translate.instant('EPIC.MSG_LIGHTBOX_DELETE_RELATED_USERSTORY', { + subject: @.userstory.get('subject') + }) + + return @confirm.askOnDelete(title, message) + .then (askResponse) => + onError = () => + message = @translate.instant('EPIC.ERROR_DELETE_RELATED_USERSTORY', {errorMessage: message}) + @confirm.notify("error", null, message) + askResponse.finish(false) + + onSuccess = () => + @.loadRelatedUserstories() + askResponse.finish() + + epicId = @.epic.get('id') + userstoryId = @.userstory.get('id') + @rs.epics.deleteRelatedUserstory(epicId, userstoryId).then(onSuccess, onError) + +module.controller("RelatedUserstoryRowCtrl", RelatedUserstoryRowController) diff --git a/app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.controller.spec.coffee b/app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.controller.spec.coffee new file mode 100644 index 00000000..c300b372 --- /dev/null +++ b/app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.controller.spec.coffee @@ -0,0 +1,169 @@ +### +# 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: related-userstory-row.controller.spec.coffee +### + +describe "RelatedUserstoryRow", -> + RelatedUserstoryRowCtrl = null + provide = null + controller = null + mocks = {} + + _mockTgConfirm = () -> + mocks.tgConfirm = { + askOnDelete: sinon.stub() + notify: sinon.stub() + } + + provide.value "$tgConfirm", mocks.tgConfirm + + _mockTgAvatarService = () -> + mocks.tgAvatarService = { + getAvatar: sinon.stub() + } + + provide.value "tgAvatarService", mocks.tgAvatarService + + _mockTranslate = () -> + mocks.translate = { + instant: sinon.stub() + } + + provide.value "$translate", mocks.translate + + _mockTgResources = () -> + mocks.tgResources = { + epics: { + deleteRelatedUserstory: sinon.stub() + } + } + + provide.value "tgResources", mocks.tgResources + + _mocks = () -> + module ($provide) -> + provide = $provide + _mockTgConfirm() + _mockTgAvatarService() + _mockTranslate() + _mockTgResources() + + return null + + beforeEach -> + module "taigaEpics" + + _mocks() + + inject ($controller) -> + controller = $controller + + RelatedUserstoryRowCtrl = controller "RelatedUserstoryRowCtrl" + + it "set avatar data", (done) -> + RelatedUserstoryRowCtrl.userstory = Immutable.fromJS({ + assigned_to_extra_info: { + id: 3 + } + }) + member = RelatedUserstoryRowCtrl.userstory.get("assigned_to_extra_info") + avatar = { + url: "http://taiga.io" + bg: "#AAAAAA" + } + mocks.tgAvatarService.getAvatar.withArgs(member).returns(avatar) + RelatedUserstoryRowCtrl.setAvatarData() + expect(mocks.tgAvatarService.getAvatar).have.been.calledWith(member) + expect(RelatedUserstoryRowCtrl.avatar).is.equal(avatar) + done() + + it "get assigned to full name display for existing user", (done) -> + RelatedUserstoryRowCtrl.userstory = Immutable.fromJS({ + assigned_to: 1 + assigned_to_extra_info: { + full_name_display: "Beta tester" + } + }) + + expect(RelatedUserstoryRowCtrl.getAssignedToFullNameDisplay()).is.equal("Beta tester") + done() + + it "get assigned to full name display for unassigned user story", (done) -> + RelatedUserstoryRowCtrl.userstory = Immutable.fromJS({ + assigned_to: null + }) + mocks.translate.instant.withArgs("COMMON.ASSIGNED_TO.NOT_ASSIGNED").returns("Unassigned") + expect(RelatedUserstoryRowCtrl.getAssignedToFullNameDisplay()).is.equal("Unassigned") + done() + + it "delete related userstory success", (done) -> + RelatedUserstoryRowCtrl.epic = Immutable.fromJS({ + id: 123 + }) + RelatedUserstoryRowCtrl.userstory = Immutable.fromJS({ + subject: "Deleting" + id: 124 + }) + + RelatedUserstoryRowCtrl.loadRelatedUserstories = sinon.stub() + + askResponse = { + finish: sinon.spy() + } + + mocks.translate.instant.withArgs("EPIC.TITLE_LIGHTBOX_DELETE_RELATED_USERSTORY").returns("title") + mocks.translate.instant.withArgs("EPIC.MSG_LIGHTBOX_DELETE_RELATED_USERSTORY", {subject: "Deleting"}).returns("message") + + mocks.tgConfirm.askOnDelete = sinon.stub() + mocks.tgConfirm.askOnDelete.withArgs("title", "message").promise().resolve(askResponse) + + promise = mocks.tgResources.epics.deleteRelatedUserstory.withArgs(123, 124).promise().resolve(true) + RelatedUserstoryRowCtrl.onDeleteRelatedUserstory().then () -> + expect(mocks.tgResources.epics.deleteRelatedUserstory).have.been.calledWith(123, 124) + expect(RelatedUserstoryRowCtrl.loadRelatedUserstories).have.been.calledOnce + expect(askResponse.finish).have.been.calledOnce + done() + + it "delete related userstory error", (done) -> + RelatedUserstoryRowCtrl.epic = Immutable.fromJS({ + id: 123 + }) + RelatedUserstoryRowCtrl.userstory = Immutable.fromJS({ + subject: "Deleting" + id: 124 + }) + + RelatedUserstoryRowCtrl.loadRelatedUserstories = sinon.stub() + + askResponse = { + finish: sinon.spy() + } + + mocks.translate.instant.withArgs("EPIC.TITLE_LIGHTBOX_DELETE_RELATED_USERSTORY").returns("title") + mocks.translate.instant.withArgs("EPIC.MSG_LIGHTBOX_DELETE_RELATED_USERSTORY", {subject: "Deleting"}).returns("message") + mocks.translate.instant.withArgs("EPIC.ERROR_DELETE_RELATED_USERSTORY", {errorMessage: "message"}).returns("error message") + + mocks.tgConfirm.askOnDelete = sinon.stub() + mocks.tgConfirm.askOnDelete.withArgs("title", "message").promise().resolve(askResponse) + + promise = mocks.tgResources.epics.deleteRelatedUserstory.withArgs(123, 124).promise().reject(new Error("error")) + RelatedUserstoryRowCtrl.onDeleteRelatedUserstory().then () -> + expect(mocks.tgResources.epics.deleteRelatedUserstory).have.been.calledWith(123, 124) + expect(RelatedUserstoryRowCtrl.loadRelatedUserstories).to.not.have.been.called + expect(askResponse.finish).have.been.calledWith(false) + expect(mocks.tgConfirm.notify).have.been.calledWith("error", null, "error message") + done() diff --git a/app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.directive.coffee b/app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.directive.coffee new file mode 100644 index 00000000..02ea4ebd --- /dev/null +++ b/app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.directive.coffee @@ -0,0 +1,42 @@ +### +# 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: related-userstory-row.directive.coffee +### + +module = angular.module('taigaEpics') + +RelatedUserstoryRowDirective = () -> + link = (scope, el, attrs, ctrl) -> + ctrl.setAvatarData() + + return { + link: link, + templateUrl:"epics/related-userstories/related-userstory-row/related-userstory-row.html", + controller: "RelatedUserstoryRowCtrl", + controllerAs: "vm", + bindToController: true, + scope: { + userstory: '=' + epic: '=' + project: '=' + loadRelatedUserstories:"&" + } + } + +RelatedUserstoryRowDirective.$inject = [] + +module.directive("tgRelatedUserstoryRow", RelatedUserstoryRowDirective) diff --git a/app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.jade b/app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.jade new file mode 100644 index 00000000..7c7b8a41 --- /dev/null +++ b/app/modules/epics/related-userstories/related-userstory-row/related-userstory-row.jade @@ -0,0 +1,44 @@ +.userstory-name + - var hash = "#"; + a( + tg-nav="project-userstories-detail:project=vm.userstory.getIn(['project_extra_info', 'slug']),ref=vm.userstory.get('ref')" + ng-attr-title="{{vm.userstory.get('subject')}}" + ) #{hash}{{vm.userstory.get('ref')}} {{vm.userstory.get('subject')}} + + tg-belong-to-epics( + format="pill" + ng-if="vm.userstory.get('epics')" + epics="vm.userstory.get('epics')" + ) + +.userstory-settings + a.delete-userstory.e2e-delete-userstory( + tg-check-permission="modify_epic" + title="{{'COMMON.DELETE' | translate}}" + href="" + ng-click="vm.onDeleteRelatedUserstory()" + ) + tg-svg(svg-icon="icon-trash") + +.project( + tg-nav="project:project=vm.userstory.getIn(['project_extra_info', 'slug'])" +) + img( + tg-project-logo-small-src="::vm.userstory.get('project_extra_info')" + alt="{{::vm.userstory.getIn(['project_extra_info', 'name'])}}" + ) + +.status + span.userstory-status(ng-style="{'color': vm.userstory.getIn(['status_extra_info', 'color'])}") {{vm.userstory.getIn(['status_extra_info', 'name'])}} + +.assigned-to-column + figure.avatar + img( + style="background-color: {{ vm.avatar.bg }}" + src="{{ vm.avatar.url }}" + alt="{{ vm.avatar.full_name_display }}" + ) + + figcaption {{ vm.getAssignedToFullNameDisplay() }} + +div(tg-related-userstories-create-form) diff --git a/app/modules/resources/epics-resource.service.coffee b/app/modules/resources/epics-resource.service.coffee index 21129745..82d48c11 100644 --- a/app/modules/resources/epics-resource.service.coffee +++ b/app/modules/resources/epics-resource.service.coffee @@ -51,6 +51,31 @@ Resource = (urlsService, http) -> return http.post(url, params) + service.addRelatedUserstory = (epicId, userstoryId) -> + url = urlsService.resolve("epic-related-userstories", epicId) + + params = { + user_story: userstoryId + epic: epicId + } + + return http.post(url, params) + + service.bulkCreateRelatedUserStories = (epicId, projectId, bulk_userstories) -> + url = urlsService.resolve("epic-related-userstories-bulk-create", epicId) + + params = { + bulk_userstories: bulk_userstories, + project_id: projectId + } + + return http.post(url, params) + + service.deleteRelatedUserstory = (epicId, userstoryId) -> + url = urlsService.resolve("epic-related-userstories", epicId) + "/#{userstoryId}" + + return http.delete(url) + return () -> return {"epics": service} diff --git a/app/modules/resources/userstories-resource.service.coffee b/app/modules/resources/userstories-resource.service.coffee index 7b7f9b90..d410036e 100644 --- a/app/modules/resources/userstories-resource.service.coffee +++ b/app/modules/resources/userstories-resource.service.coffee @@ -33,6 +33,22 @@ Resource = (urlsService, http) -> .then (result) -> return Immutable.fromJS(result.data) + service.listAllInProject = (projectId) -> + url = urlsService.resolve("userstories") + + httpOptions = { + headers: { + "x-disable-pagination": "1" + } + } + + params = { + project: projectId + } + return http.get(url, params, httpOptions) + .then (result) -> + return Immutable.fromJS(result.data) + service.listInEpic = (epicIid) -> url = urlsService.resolve("userstories") diff --git a/app/partials/epic/epic-detail.jade b/app/partials/epic/epic-detail.jade new file mode 100644 index 00000000..b03ec020 --- /dev/null +++ b/app/partials/epic/epic-detail.jade @@ -0,0 +1,127 @@ +doctype html + +div.wrapper( + ng-controller="EpicDetailController as ctrl", + ng-init="section='epics'" +) + tg-project-menu + + div.main.us-detail + div.us-detail-header.header-with-actions + include ../includes/components/mainTitle + + section.us-story-main-data + header + tg-vote-button.upvote-btn( + item="epic" + on-upvote="ctrl.onUpvote" + on-downvote="ctrl.onDownvote" + ) + + .detail-header-container + tg-color-selector( + color="epic.color", + on-select-color="ctrl.onSelectColor(color)" + ) + tg-detail-header( + item="epic" + project="project" + required-perm="modify_epic" + ng-class="{blocked: epic.is_blocked}" + ng-if="project && epic" + format="text" + ) + .subheader + tg-tag-line.tags-block( + ng-if="epic && project" + project="project" + item="epic" + permissions="modify_epic" + ) + tg-created-by-display.ticket-created-by(ng-model="epic") + + section.duty-content( + tg-editable-description + tg-editable-wysiwyg + ng-model="epic" + required-perm="modify_epic" + ) + + // Custom Fields + tg-custom-attributes-values( + ng-model="epic" + type="epic" + project="project" + required-edition-perm="modify_epic" + ) + + tg-related-userstories( + project="immutableProject" + userstories="userstories" + epic="immutableEpic" + ) + + tg-attachments-full( + obj-id="epic.id" + type="epic", + project-id="projectId" + edit-permission = "modify_epic" + ) + + tg-history-section( + ng-if="epic" + type="epic" + name="epic" + id="epic.id" + project-id="projectId" + ) + + sidebar.menu-secondary.sidebar.ticket-data + + .ticket-header + span.ticket-title( + tg-epic-status-display + ng-model="epic" + ) + span.detail-status( + tg-epic-status-button + ng-model="epic" + ) + + section.ticket-assigned-to( + tg-assigned-to + ng-model="epic" + required-perm="modify_epic" + ) + + section.ticket-watch-buttons + div.ticket-watch( + tg-watch-button + item="epic" + data-environment="ticket" + on-watch="ctrl.onWatch" + on-unwatch="ctrl.onUnwatch" + ) + div.ticket-watchers( + tg-watchers + ng-model="epic" + required-perm="modify_epic" + ) + + section.ticket-detail-settings + tg-us-team-requirement-button(ng-model="epic") + tg-us-client-requirement-button(ng-model="epic") + tg-block-button( + tg-check-permission="modify_epic", + ng-model="epic" + ) + tg-delete-button( + tg-check-permission="delete_epic", + on-delete-title="{{'EPIC.ACTION_DELETE' | translate}}", + on-delete-go-to-url="onDeleteGoToUrl", + ng-model="epic" + ) + + div.lightbox.lightbox-block(tg-lb-block, ng-model="epic", title="EPIC.LIGHTBOX_TITLE_BLOKING_EPIC") + div.lightbox.lightbox-select-user(tg-lb-assignedto) + div.lightbox.lightbox-select-user(tg-lb-watchers) diff --git a/app/styles/modules/common/wizard.scss b/app/styles/modules/common/wizard.scss index f0482026..5bcae75f 100644 --- a/app/styles/modules/common/wizard.scss +++ b/app/styles/modules/common/wizard.scss @@ -57,7 +57,6 @@ .icon { @include svg-size(1.5rem); fill: currentColor; - margin-right: 1rem; vertical-align: text-top; } .template-name { diff --git a/conf.e2e.js b/conf.e2e.js index d7ca0a01..cd421c09 100644 --- a/conf.e2e.js +++ b/conf.e2e.js @@ -53,55 +53,55 @@ var config = { onPrepare: function() { // disable by default because performance problems on IE // track mouse movements - // var trackMouse = function() { - // angular.module('trackMouse', []).run(function($document) { + var trackMouse = function() { + angular.module('trackMouse', []).run(function($document) { - // function addDot(ev) { - // var color = 'black', - // size = 6; + function addDot(ev) { + var color = 'black', + size = 6; - // switch (ev.type) { - // case 'click': - // color = 'red'; - // break; - // case 'dblclick': - // color = 'blue'; - // break; - // case 'mousemove': - // color = 'green'; - // break; - // } + switch (ev.type) { + case 'click': + color = 'red'; + break; + case 'dblclick': + color = 'blue'; + break; + case 'mousemove': + color = 'green'; + break; + } - // var dotEl = $('
') - // .css({ - // position: 'fixed', - // height: size + 'px', - // width: size + 'px', - // 'background-color': color, - // top: ev.clientY, - // left: ev.clientX, + var dotEl = $('
') + .css({ + position: 'fixed', + height: size + 'px', + width: size + 'px', + 'background-color': color, + top: ev.clientY, + left: ev.clientX, - // 'z-index': 9999, + 'z-index': 9999, - // // make sure this dot won't interfere with the mouse events of other elements - // 'pointer-events': 'none' - // }) - // .appendTo('body'); + // make sure this dot won't interfere with the mouse events of other elements + 'pointer-events': 'none' + }) + .appendTo('body'); - // setTimeout(function() { - // dotEl.remove(); - // }, 1000); - // } + setTimeout(function() { + dotEl.remove(); + }, 1000); + } - // $document.on({ - // click: addDot, - // dblclick: addDot, - // mousemove: addDot - // }); + $document.on({ + click: addDot, + dblclick: addDot, + mousemove: addDot + }); - // }); - // }; - // browser.addMockModule('trackMouse', trackMouse); + }); + }; + browser.addMockModule('trackMouse', trackMouse); browser.params.glob.back = argv.back; diff --git a/e2e/helpers/detail-helper.js b/e2e/helpers/detail-helper.js index 78daae62..a315edbb 100644 --- a/e2e/helpers/detail-helper.js +++ b/e2e/helpers/detail-helper.js @@ -86,7 +86,7 @@ helper.tags = function() { for (let tag of tags){ htmlChanges = await utils.common.outerHtmlChanges(el.$(".tags-container")); el.$('.e2e-add-tag-input').sendKeys(tag); - await browser.actions().sendKeys(protractor.Key.ENTER).perform(); + el.$('.save').click(); await htmlChanges(); } } @@ -542,3 +542,43 @@ helper.watchersLightbox = function() { return obj; }; + +helper.teamRequirement = function() { + let el = $('tg-us-team-requirement-button'); + + let obj = { + el: el, + + toggleStatus: async function(){ + await el.$("label").click(); + await browser.waitForAngular(); + }, + + isRequired: async function() { + let classes = await el.$("label").getAttribute('class'); + return classes.includes("active"); + } + }; + + return obj; +}; + +helper.clientRequirement = function() { + let el = $('tg-us-client-requirement-button'); + + let obj = { + el: el, + + toggleStatus: async function(){ + await el.$("label").click(); + await browser.waitForAngular(); + }, + + isRequired: async function() { + let classes = await el.$("label").getAttribute('class'); + return classes.includes("active"); + } + }; + + return obj; +}; diff --git a/e2e/helpers/epic-detail-helper.js b/e2e/helpers/epic-detail-helper.js new file mode 100644 index 00000000..84368661 --- /dev/null +++ b/e2e/helpers/epic-detail-helper.js @@ -0,0 +1,76 @@ +var utils = require('../utils'); +var commonHelper = require('./common-helper'); + +var helper = module.exports; + + +helper.colorEditor = function() { + let el = $('tg-color-selector'); + + let obj = { + el: el, + + open: async function(){ + await el.$(".e2e-open-color-selector").click(); + }, + + selectFirstColor: async function() { + let color = el.$$(".color-selector-option").first(); + color.click(); + await browser.waitForAngular(); + }, + + selectLastColor: async function() { + let color = el.$$(".color-selector-option").last(); + color.click(); + await browser.waitForAngular(); + } + }; + + return obj; +}; + +helper.relatedUserstories = function() { + let el = $('tg-related-userstories'); + + let obj = { + el: el, + + createNewUserStory: async function(subject) { + el.$(".e2e-add-userstory-button").click(); + el.$(".e2e-new-userstory-label").click(); + el.$(".e2e-single-creation-label").click(); + el.$(".e2e-new-userstory-input-text").sendKeys(subject); + el.$(".e2e-create-userstory-button").click(); + await browser.waitForAngular(); + }, + + createNewUserStories: async function(subject) { + el.$(".e2e-add-userstory-button").click(); + el.$(".e2e-new-userstory-label").click(); + el.$(".e2e-bulk-creation-label").click(); + el.$(".e2e-new-userstories-input-textarea").sendKeys(subject); + el.$(".e2e-create-userstory-button").click(); + await browser.waitForAngular(); + }, + + selectFirstRelatedUserstory: async function() { + el.$(".e2e-add-userstory-button").click(); + el.$(".e2e-existing-user-story-label").click(); + el.$(".e2e-filter-userstories-input").click().sendKeys("#1"); + await browser.waitForAngular(); + el.$$(".e2e-userstories-select option").get(1).click() + el.$(".e2e-select-related-userstory-button").click(); + await browser.waitForAngular(); + }, + + deleteFirstRelatedUserstory: async function() { + let relatedUSRow = el.$$("tg-related-userstory-row").first(); + browser.actions().mouseMove(relatedUSRow).perform(); + relatedUSRow.$(".e2e-delete-userstory").click(); + await utils.lightbox.confirm.ok(); + } + }; + + return obj; +} diff --git a/e2e/helpers/epics-helper.js b/e2e/helpers/epics-dashboard-helper.js similarity index 94% rename from e2e/helpers/epics-helper.js rename to e2e/helpers/epics-dashboard-helper.js index 305f7a27..787acd1c 100644 --- a/e2e/helpers/epics-helper.js +++ b/e2e/helpers/epics-dashboard-helper.js @@ -44,33 +44,38 @@ helper.epic = function() { resetAssignedTo: async function() { el.get(0).$('.e2e-assigned-to-image').click(); $$('.e2e-assigned-to-selector').get(0).click(); + await browser.waitForAngular(); }, editAssignedTo: async function() { el.get(0).$('.e2e-assigned-to-image').click(); utils.common.takeScreenshot("epics", "epics-edit-assigned"); $$('.e2e-assigned-to-selector').last().click(); + await browser.waitForAngular(); }, removeAssignedTo: async function() { el.get(0).$('.e2e-assigned-to-image').click(); - $$('.e2e-unassign').click(); + $('.e2e-unassign').click(); + await browser.waitForAngular(); return el.get(0).$('.e2e-assigned-to-image').getAttribute("alt"); }, - resetStatus: function() { + resetStatus: async function() { el.get(0).$('.e2e-epic-status').click(); el.get(0).$$('.e2e-edit-epic-status').get(0).click(); + await browser.waitForAngular(); }, getStatus: function() { return el.get(0).$('.e2e-epic-status').getText(); }, - editStatus: function() { + editStatus: async function() { el.get(0).$('.e2e-epic-status').click(); utils.common.takeScreenshot("epics", "epics-edit-status"); el.get(0).$$('.e2e-edit-epic-status').last().click(); + await browser.waitForAngular(); }, getColumns: function() { return $$('.e2e-epics-table-header > div').count(); }, - removeColumns: function() { + removeColumns: async function() { $('.e2e-epics-column-button').click(); utils.common.takeScreenshot("epics", "epics-edit-columns"); $$('.e2e-epics-column-dropdown .check').first().click(); diff --git a/e2e/helpers/index.js b/e2e/helpers/index.js index bc497ffc..307b9daf 100644 --- a/e2e/helpers/index.js +++ b/e2e/helpers/index.js @@ -13,3 +13,5 @@ module.exports.adminPermissions = require("./admin-permissions"); module.exports.adminIntegrations = require("./admin-integrations"); module.exports.issues = require("./issues-helper"); module.exports.createProject = require("./create-project-helper"); +module.exports.epicsDashboard = require("./epics-dashboard-helper"); +module.exports.epicDetail = require("./epic-detail-helper"); diff --git a/e2e/helpers/us-detail-helper.js b/e2e/helpers/us-detail-helper.js index c41f53af..b419d433 100644 --- a/e2e/helpers/us-detail-helper.js +++ b/e2e/helpers/us-detail-helper.js @@ -3,45 +3,6 @@ var commonHelper = require('./common-helper'); var helper = module.exports; -helper.teamRequirement = function() { - let el = $('tg-us-team-requirement-button'); - - let obj = { - el: el, - - toggleStatus: async function(){ - await el.$("label").click(); - await browser.waitForAngular(); - }, - - isRequired: async function() { - let classes = await el.$("label").getAttribute('class'); - return classes.includes("active"); - } - }; - - return obj; -}; - -helper.clientRequirement = function() { - let el = $('tg-us-client-requirement-button'); - - let obj = { - el: el, - - toggleStatus: async function(){ - await el.$("label").click(); - await browser.waitForAngular(); - }, - - isRequired: async function() { - let classes = await el.$("label").getAttribute('class'); - return classes.includes("active"); - } - }; - - return obj; -}; helper.relatedTaskForm = async function(form, name, status, assigned_to) { await form.$('input').sendKeys(name); diff --git a/e2e/shared/detail.js b/e2e/shared/detail.js index 3024a595..f8694cbd 100644 --- a/e2e/shared/detail.js +++ b/e2e/shared/detail.js @@ -274,12 +274,12 @@ shared.blockTesting = async function() { let descriptionText = await $('.block-description').getText(); expect(descriptionText).to.be.equal('This is a testing block reason'); - let isDisplayed = $('.block-description').isDisplayed(); + let isDisplayed = $('.block-desc-container').isDisplayed(); expect(isDisplayed).to.be.equal.true; blockHelper.unblock(); - isDisplayed = $('.block-description').isDisplayed(); + isDisplayed = $('.block-desc-container').isDisplayed(); expect(isDisplayed).to.be.equal.false; await notifications.success.close(); @@ -548,3 +548,37 @@ shared.customFields = function(typeIndex) { expect(fieldText).to.be.equal('test text2 edit'); }); }; + +shared.teamRequirementTesting = function() { + it('team requirement edition', async function() { + let requirementHelper = detailHelper.teamRequirement(); + let isRequired = await requirementHelper.isRequired(); + + // Toggle + requirementHelper.toggleStatus(); + let newIsRequired = await requirementHelper.isRequired(); + expect(isRequired).to.be.not.equal(newIsRequired); + + // Toggle again + requirementHelper.toggleStatus(); + newIsRequired = await requirementHelper.isRequired(); + expect(isRequired).to.be.equal(newIsRequired); + }); +} + +shared.clientRequirementTesting = function () { + it('client requirement edition', async function() { + let requirementHelper = detailHelper.clientRequirement(); + let isRequired = await requirementHelper.isRequired(); + + // Toggle + requirementHelper.toggleStatus(); + let newIsRequired = await requirementHelper.isRequired(); + expect(isRequired).to.be.not.equal(newIsRequired); + + // Toggle again + requirementHelper.toggleStatus(); + newIsRequired = await requirementHelper.isRequired(); + expect(isRequired).to.be.equal(newIsRequired); + }); +} diff --git a/e2e/suites/admin/attributes/custom-fields.e2e.js b/e2e/suites/admin/attributes/custom-fields.e2e.js index 0a61db8c..aef42ac7 100644 --- a/e2e/suites/admin/attributes/custom-fields.e2e.js +++ b/e2e/suites/admin/attributes/custom-fields.e2e.js @@ -16,8 +16,64 @@ describe('custom-fields', function() { }); describe('create custom fields', function() { + describe('epics', function() { + let typeIndex = 0; + + it('create', async function() { + let oldCountCustomFields = await customFieldsHelper.getCustomFiledsByType(typeIndex).count(); + + await customFieldsHelper.create(typeIndex, 'test1-text', 'desc1', 1); + + // debounce :( + await utils.notifications.success.open(); + await browser.sleep(2000); + + await customFieldsHelper.create(typeIndex, 'test1-multi', 'desc1', 3); + + // debounce :( + await utils.notifications.success.open(); + await browser.sleep(2000); + + let countCustomFields = await customFieldsHelper.getCustomFiledsByType(typeIndex).count(); + + expect(countCustomFields).to.be.equal(oldCountCustomFields + 2); + }); + + it('edit', async function() { + customFieldsHelper.edit(typeIndex, 0, 'edit', 'desc2', 2); + + let open = await utils.notifications.success.open(); + + expect(open).to.be.true; + + await utils.notifications.success.close(); + }); + + it('drag', async function() { + let nameOld = await customFieldsHelper.getName(typeIndex, 0); + + await customFieldsHelper.drag(typeIndex, 0, 1); + + let nameNew = await customFieldsHelper.getName(typeIndex, 1); + + expect(nameNew).to.be.equal(nameOld); + }); + + it('delete', async function() { + let oldCountCustomFields = await customFieldsHelper.getCustomFiledsByType(typeIndex).count(); + + await customFieldsHelper.delete(typeIndex, 0); + + await browser.wait(async function() { + let countCustomFields = await customFieldsHelper.getCustomFiledsByType(typeIndex).count(); + + return countCustomFields === oldCountCustomFields - 1; + }, 4000); + }); + }); + describe('userstories', function() { - let typeIndex = 0; + let typeIndex = 1; it('create', async function() { let oldCountCustomFields = await customFieldsHelper.getCustomFiledsByType(typeIndex).count(); @@ -73,7 +129,7 @@ describe('custom-fields', function() { }); describe('tasks', function() { - let typeIndex = 1; + let typeIndex = 2; it('create', async function() { let oldCountCustomFields = await customFieldsHelper.getCustomFiledsByType(typeIndex).count(); @@ -126,7 +182,7 @@ describe('custom-fields', function() { }); describe('issues', function() { - let typeIndex = 2; + let typeIndex = 3; it('create', async function() { let oldCountCustomFields = await customFieldsHelper.getCustomFiledsByType(typeIndex).count(); @@ -180,5 +236,6 @@ describe('custom-fields', function() { }, 4000); }); }); + }); }); diff --git a/e2e/suites/admin/members.e2e.js b/e2e/suites/admin/members.e2e.js index 2cbf6904..5178b139 100644 --- a/e2e/suites/admin/members.e2e.js +++ b/e2e/suites/admin/members.e2e.js @@ -8,7 +8,7 @@ var chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); var expect = chai.expect; -describe.only('admin - members', function() { +describe('admin - members', function() { before(async function(){ browser.get(browser.params.glob.host + 'project/project-0/admin/memberships'); diff --git a/e2e/suites/epics/epic-dashboard.e2e.js b/e2e/suites/epics/epic-dashboard.e2e.js index 370cc6af..9eb5d606 100644 --- a/e2e/suites/epics/epic-dashboard.e2e.js +++ b/e2e/suites/epics/epic-dashboard.e2e.js @@ -1,5 +1,5 @@ var utils = require('../../utils'); -var epicsHelper = require('../../helpers/epics-helper'); +var epicsDashboardHelper = require('../../helpers').epicsDashboard; var chai = require('chai'); var chaiAsPromised = require('chai-as-promised'); @@ -8,7 +8,7 @@ chai.use(chaiAsPromised); var expect = chai.expect; describe('Epics Dashboard', function(){ - let usUrl = ''; + let epicsUrl = ''; before(async function(){ await utils.nav @@ -17,7 +17,7 @@ describe('Epics Dashboard', function(){ .epics() .go(); - usUrl = await browser.getCurrentUrl(); + epicsUrl = await browser.getCurrentUrl(); }); it('screenshot', async function() { @@ -25,13 +25,23 @@ describe('Epics Dashboard', function(){ }); it('display child stories', async function() { - let epic = epicsHelper.epic(); + let epic = epicsDashboardHelper.epic(); let childStoriesNum = await epic.displayUserStoriesinEpic(); expect(childStoriesNum).to.be.above(0); }); + it('create Epic', async function() { + let date = Date.now(); + let description = Math.random().toString(36).substring(7); + let epic = epicsDashboardHelper.epic(); + let currentEpicsNum = await epic.getEpics(); + await epic.createEpic(date, description); + let newEpicsNum = await epic.getEpics(); + expect(newEpicsNum).to.be.above(currentEpicsNum); + }); + it('change epic assigned from dashboard', async function() { - let epic = epicsHelper.epic(); + let epic = epicsDashboardHelper.epic(); await epic.resetAssignedTo(); let currentAssigned = await epic.getAssignedTo(); await epic.editAssignedTo(); @@ -40,15 +50,14 @@ describe('Epics Dashboard', function(){ }); it('remove assigned from dashboard', async function() { - let epic = epicsHelper.epic(); + let epic = epicsDashboardHelper.epic(); await epic.resetAssignedTo(); let unAssigned = await epic.removeAssignedTo(); - console.log(unAssigned); expect(unAssigned).to.be.equal('Unassigned'); }); it('change status from dashboard', async function() { - let epic = epicsHelper.epic(); + let epic = epicsDashboardHelper.epic(); await epic.resetStatus(); let currentStatus = await epic.getStatus(); await epic.editStatus(); @@ -57,22 +66,11 @@ describe('Epics Dashboard', function(){ }); it('remove columns from dashboard', async function() { - let epic = epicsHelper.epic(); + let epic = epicsDashboardHelper.epic(); let currentColumns = await epic.getColumns(); await epic.removeColumns(); let newColumns = await epic.getColumns(); expect(currentColumns).to.be.above(newColumns); }); - it.only('create Epic', async function() { - let date = Date.now(); - let description = Math.random().toString(36).substring(7); - let epic = epicsHelper.epic(); - let currentEpicsNum = await epic.getEpics(); - await epic.createEpic(date, description); - let newEpicsNum = await epic.getEpics(); - console.log(currentEpicsNum, newEpicsNum); - expect(newEpicsNum).to.be.above(currentEpicsNum); - }); - }) diff --git a/e2e/suites/epics/epic-detail.e2e.js b/e2e/suites/epics/epic-detail.e2e.js new file mode 100644 index 00000000..66c5e34a --- /dev/null +++ b/e2e/suites/epics/epic-detail.e2e.js @@ -0,0 +1,100 @@ +var utils = require('../../utils'); +var sharedDetail = require('../../shared/detail'); +var epicDetailHelper = require('../../helpers').epicDetail; + +var chai = require('chai'); +var chaiAsPromised = require('chai-as-promised'); + +chai.use(chaiAsPromised); +var expect = chai.expect; + +describe('Epic detail', async function(){ + let epicUrl = ''; + + before(async function(){ + await utils.nav + .init() + .project('Project Example 0') + .epics() + .epic(0) + .go(); + + epicUrl = await browser.getCurrentUrl(); + }); + + it('screenshot', async function() { + await utils.common.takeScreenshot("epics", "detail"); + }); + + it('color edition', async function() { + let colorEditor = epicDetailHelper.colorEditor(); + await colorEditor.open(); + await colorEditor.selectFirstColor(); + await colorEditor.open(); + await colorEditor.selectLastColor(); + await utils.common.takeScreenshot("epics", "detail color updated"); + }); + + it('title edition', sharedDetail.titleTesting); + + it('tags edition', sharedDetail.tagsTesting); + + describe('description', sharedDetail.descriptionTesting); + + describe('related userstories', function() { + let relatedUserstories = epicDetailHelper.relatedUserstories(); + it('create new user story', async function(){ + await relatedUserstories.createNewUserStory("Testing subject"); + }); + + it('create new user stories in bulk', async function(){ + await relatedUserstories.createNewUserStories("Testing subject1\nTesting subject 2"); + }); + + it('add related userstory', async function(){ + await relatedUserstories.selectFirstRelatedUserstory(); + }); + + it('delete related userstory', async function(){ + await relatedUserstories.deleteFirstRelatedUserstory(); + }) + }); + + it('status edition', sharedDetail.statusTesting.bind(this, 'Ready', 'In progress')); + + describe('assigned to edition', sharedDetail.assignedToTesting); + + describe('watchers edition', sharedDetail.watchersTesting); + + it('history', sharedDetail.historyTesting.bind(this, "epics")); + + it('block', sharedDetail.blockTesting); + + describe('team requirement edition', sharedDetail.teamRequirementTesting); + + describe('client requirement edition', sharedDetail.clientRequirementTesting); + + it('attachments', sharedDetail.attachmentTesting); + + describe('custom-fields', sharedDetail.customFields.bind(this, 0)); + + it('screenshot', async function() { + await utils.common.takeScreenshot("epics", "detail updated"); + }); + + describe('delete & redirect', function() { + it('delete', sharedDetail.deleteTesting); + + it('redirected', async function (){ + let url = await browser.getCurrentUrl(); + expect(url).not.to.be.equal(epicUrl); + }); + }); + +}); + + +/* +TODO: +# Related user stories +*/ diff --git a/e2e/suites/user-stories/user-story-detail.e2e.js b/e2e/suites/user-stories/user-story-detail.e2e.js index 9727efa6..bbc52509 100644 --- a/e2e/suites/user-stories/user-story-detail.e2e.js +++ b/e2e/suites/user-stories/user-story-detail.e2e.js @@ -36,35 +36,9 @@ describe('User story detail', function(){ describe('assigned to edition', sharedDetail.assignedToTesting); - it('team requirement edition', async function() { - let requirementHelper = usDetailHelper.teamRequirement(); - let isRequired = await requirementHelper.isRequired(); + describe('team requirement edition', sharedDetail.teamRequirementTesting); - // Toggle - requirementHelper.toggleStatus(); - let newIsRequired = await requirementHelper.isRequired(); - expect(isRequired).to.be.not.equal(newIsRequired); - - // Toggle again - requirementHelper.toggleStatus(); - newIsRequired = await requirementHelper.isRequired(); - expect(isRequired).to.be.equal(newIsRequired); - }); - - it('client requirement edition', async function() { - let requirementHelper = usDetailHelper.clientRequirement(); - let isRequired = await requirementHelper.isRequired(); - - // Toggle - requirementHelper.toggleStatus(); - let newIsRequired = await requirementHelper.isRequired(); - expect(isRequired).to.be.not.equal(newIsRequired); - - // Toggle again - requirementHelper.toggleStatus(); - newIsRequired = await requirementHelper.isRequired(); - expect(isRequired).to.be.equal(newIsRequired); - }); + describe('client requirement edition', sharedDetail.clientRequirementTesting); describe('watchers edition', sharedDetail.watchersTesting); diff --git a/e2e/utils/nav.js b/e2e/utils/nav.js index 17710dbe..b3c32daa 100644 --- a/e2e/utils/nav.js +++ b/e2e/utils/nav.js @@ -46,11 +46,21 @@ var actions = { return common.waitLoader(); }, + epics: async function() { await common.link($('#nav-epics a')); return common.waitLoader(); }, + + epic: async function(index) { + let epic = $$('.e2e-epic-row .name a').get(index); + + await common.link(epic); + + return common.waitLoader(); + }, + backlog: async function() { await common.link($$('#nav-backlog a').first()); @@ -110,6 +120,10 @@ var nav = { this.actions.push(actions.epics.bind(null, index)); return this; }, + epic: function(index) { + this.actions.push(actions.epic.bind(null, index)); + return this; + }, backlog: function(index) { this.actions.push(actions.backlog.bind(null, index)); return this; diff --git a/gulpfile.js b/gulpfile.js index b69e5c92..3fbc02a5 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -130,6 +130,7 @@ paths.coffee_order = [ paths.app + "coffee/modules/backlog/*.coffee", paths.app + "coffee/modules/taskboard/*.coffee", paths.app + "coffee/modules/kanban/*.coffee", + paths.app + "coffee/modules/epics/*.coffee", paths.app + "coffee/modules/issues/*.coffee", paths.app + "coffee/modules/userstories/*.coffee", paths.app + "coffee/modules/tasks/*.coffee", diff --git a/run-e2e.js b/run-e2e.js index 77d8e48b..56d78341 100644 --- a/run-e2e.js +++ b/run-e2e.js @@ -12,6 +12,7 @@ var suites = [ 'wiki', 'admin', 'issues', + 'epics', 'tasks', 'userProfile', 'userStories',