From 7ffa2ee292c18f9352835b1bbce6aa89af4adb43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Tue, 6 Mar 2018 11:29:08 +0100 Subject: [PATCH] Relate to Epic button & lightbox --- app/coffee/modules/common/lightboxes.coffee | 118 +++++++++++++++++- app/coffee/modules/resources/projects.coffee | 6 +- app/locales/taiga/locale-en.json | 18 +++ .../belong-to-epics/belong-to-epics-text.jade | 30 +++-- .../belong-to-epics.directive.coffee | 42 ++++++- .../belong-to-epics/belong-to-epics.scss | 67 +++++++--- .../header/detail-header.controller.coffee | 3 + .../detail/header/detail-header.jade | 26 ++-- .../detail/header/detail-header.scss | 16 +++ app/modules/epics/epics.service.coffee | 18 ++- .../lightbox/lightbox-relate-to-epic.jade | 106 ++++++++++++++++ app/partials/us/us-detail.jade | 1 + app/styles/modules/common/lightbox.scss | 91 +++++++++++++- 13 files changed, 489 insertions(+), 53 deletions(-) create mode 100644 app/partials/common/lightbox/lightbox-relate-to-epic.jade diff --git a/app/coffee/modules/common/lightboxes.coffee b/app/coffee/modules/common/lightboxes.coffee index 06209cc4..055d172b 100644 --- a/app/coffee/modules/common/lightboxes.coffee +++ b/app/coffee/modules/common/lightboxes.coffee @@ -1122,4 +1122,120 @@ module.directive("tgLbCreateEdit", [ "$tgTemplate", "$compile", CreateEditDirective -]) \ No newline at end of file +]) + + +############################################################################# +## RelateToEpic Lightbox Directive +############################################################################# + +debounceLeading = @.taiga.debounceLeading + +RelateToEpicLightboxDirective = ($rootScope, $confirm, lightboxService, tgCurrentUserService +tgResources, $tgResources, $epicsService, tgAnalytics) -> + link = ($scope, $el, $attrs) -> + us = null + + $scope.projects = null + $scope.projectEpics = Immutable.List() + $scope.loading = false + + newEpicForm = $el.find(".new-epic-form").checksley() + existingEpicForm = $el.find(".existing-epic-form").checksley() + + loadProjects = -> + if $scope.projects == null + $tgResources.projects.list({ + blocked_code: 'null', + is_epics_activated: true + }).then (data) -> + $scope.projects = data + + filterEpics = (selectedProjectId, filterText) -> + tgResources.epics.listInAllProjects( + { + is_epics_activated: true, + project__blocked_code: 'null', + project: selectedProjectId, + q: filterText + }, true).then (data) -> + excludeIds = [] + if (us.epics) + excludeIds = us.epics.map((epic) -> epic.id) + filteredData = data.filter((epic) -> excludeIds.indexOf(epic.get('id')) == -1) + $scope.projectEpics = filteredData + + $el.on "click", ".close", (event) -> + event.preventDefault() + lightboxService.close($el) + + $scope.$on "relate-to-epic:add", (ctx, item) -> + us = item + $scope.selectedEpic = null + $scope.searchEpic = "" + loadProjects() + filterEpics(item.projectId, $scope.searchEpic).then () -> + lightboxService.open($el).then -> + $el.find('input').focus + + $scope.$on "$destroy", -> + $el.off() + + $scope.onUpdateSearchEpic = debounceLeading 300, () -> + $scope.selectedEpic = null + filterEpics($scope.selectedProject, $scope.searchEpic) + + $scope.saveRelatedEpic = (selectedEpicId, onSavedRelatedEpic) -> + return if not existingEpicForm.validate() + + $scope.loading = true + + onError = (data) -> + $scope.loading = false + $confirm.notify("error") + existingEpicForm.setErrors(data) + + onSuccess = (data) -> + tgAnalytics.trackEvent( + "user story related epic", "create", "create related epic on user story", 1) + $scope.loading = false + $rootScope.$broadcast("related-epics:changed", us) + lightboxService.close($el) + + usId = us.id + tgResources.epics.addRelatedUserstory(selectedEpicId, usId).then( + onSuccess, onError) + + $scope.createEpic = (selectedProjectId, epicSubject) -> + return if not newEpicForm.validate() + + @.loading = true + + onError = (data)-> + $scope.loading = false + $confirm.notify("error") + newEpicForm.setErrors(errors) + + onSuccess = () -> + tgAnalytics.trackEvent( + "user story related epic", "create", "create related epic on user story", 1) + $scope.loading = false + $rootScope.$broadcast("related-epics:changed", us) + lightboxService.close($el) + + onCreateEpic = (epic) -> + epicId = epic.get('id') + usId = us.id + tgResources.epics.addRelatedUserstory(epicId, usId).then(onSuccess, onError) + + $epicsService.createEpic( + {subject: epicSubject}, null, selectedProjectId).then(onCreateEpic, onError) + + return { + templateUrl: "common/lightbox/lightbox-relate-to-epic.html" + link:link + } + +module.directive("tgLbRelatetoepic", [ + "$rootScope", "$tgConfirm", "lightboxService", "tgCurrentUserService", "tgResources", + "$tgResources", "tgEpicsService", "$tgAnalytics", RelateToEpicLightboxDirective]) diff --git a/app/coffee/modules/resources/projects.coffee b/app/coffee/modules/resources/projects.coffee index 1ca6716e..6da6095c 100644 --- a/app/coffee/modules/resources/projects.coffee +++ b/app/coffee/modules/resources/projects.coffee @@ -36,8 +36,10 @@ resourceProvider = ($config, $repo, $http, $urls, $auth, $q, $translate) -> service.getBySlug = (projectSlug) -> return $repo.queryOne("projects", "by_slug?slug=#{projectSlug}") - service.list = -> - return $repo.queryMany("projects") + service.list = (filters) -> + params = {"order_by": "user_order"} + params = _.extend({}, params, filters or {}) + return $repo.queryMany("projects", params) service.listByMember = (memberId) -> params = {"member": memberId, "order_by": "user_order"} diff --git a/app/locales/taiga/locale-en.json b/app/locales/taiga/locale-en.json index 45790835..b36426e5 100644 --- a/app/locales/taiga/locale-en.json +++ b/app/locales/taiga/locale-en.json @@ -1127,6 +1127,10 @@ "DELETE_SPRINT": { "TITLE": "Delete sprint" }, + "REMOVE_RELATIONSHIP_WITH_EPIC": { + "TITLE": "Remove relationship with Epic", + "MESSAGE": "the relationship of this User Story with the Epic {{epicSubject}}" + }, "CREATE_MEMBER": { "PLACEHOLDER_INVITATION_TEXT": "(Optional) Add a personalized text to the invitation. Tell something lovely to your new members ;-)", "PLACEHOLDER_TYPE_EMAIL": "Type an Email", @@ -1167,6 +1171,18 @@ "IN_THREE_MONTHS": "In three months" }, "TITLE_ACTION_DELETE_DUE_DATE": "Delete due date" + }, + "RELATE_TO_EPIC": { + "TITLE": "Relate to Epic", + "EXISTING_EPIC": "Existing epic", + "NEW_EPIC": "New epic", + "CHOOSE_PROJECT_FOR_CREATION": "What's the project?", + "CHOOSE_PROJECT_FROM": "What's the project?", + "SUBJECT": "Subject", + "SUBJECT_BULK_MODE": "Subject (bulk insert)", + "CHOOSE_EPIC": "What's the epic?", + "FILTER_EPICS": "Filter epics", + "NO_EPICS_FOUND": "It looks like nothing was found with your search criteria" } }, "EPIC": { @@ -1205,6 +1221,8 @@ "LIGHTBOX_TITLE_BLOKING_US": "Blocking us", "NOT_ESTIMATED": "Not estimated", "OWNER_US": "This User Story belongs to", + "RELATE_TO_EPIC": "Relate to Epic", + "REMOVE_RELATIONSHIP_WITH_EPIC": "Remove Epic relationship", "TRIBE": { "PUBLISH": "Publish as Gig in Taiga Tribe", "PUBLISH_INFO": "More info", 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 1774aa34..048b8c22 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 @@ -1,11 +1,19 @@ -- var hash = "#"; -span.belong-to-epic-text-wrapper(tg-repeat="epic in immutable_epics track by epic.get('id')") - a.belong-to-epic-text( - href="" - tg-nav="project-epics-detail:project=epic.getIn(['project', 'slug']),ref=epic.get('ref')" - ng-bind-html="'#'+epic.get('ref')+' '+epic.get('subject') | emojify" - ) - span.belong-to-epic-label( - ng-style="::{'background-color': epic.get('color')}" - translate="EPICS.EPIC" - ) +span(ng-if="epicsLength > 0", translate="US.OWNER_US") +span(ng-if="epicsLength > 1") : +ul.belong-to-epics-list(ng-class="{'unique': epicsLength == 1 }") + li.belong-to-epic-text-wrapper(tg-repeat="epic in immutable_epics track by epic.get('id')") + a.belong-to-epic-text( + href="" + tg-nav="project-epics-detail:project=epic.getIn(['project', 'slug']),ref=epic.get('ref')" + ng-bind-html="'#'+epic.get('ref')+' '+epic.get('subject') | emojify" + ) + span.belong-to-epic-label( + ng-style="::{'background-color': epic.get('color')}" + translate="EPICS.EPIC" + ) + a.remove-epic-relationship( + title="{{'US.REMOVE_RELATIONSHIP_WITH_EPIC' | translate}}" + href="" + ng-click="removeEpicRelationship(epic)" + ) + tg-svg(svg-icon="icon-close") 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 0d6101d1..b4b3b9b8 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 @@ -19,12 +19,43 @@ module = angular.module('taigaEpics') -BelongToEpicsDirective = () -> +BelongToEpicsDirective = ($translate, $confirm, $rs, $rs2, lightboxService) -> link = (scope, el, attrs) -> scope.$watch 'epics', (epics) -> + updateEpics(epics) + + scope.$on "related-epics:changed", (ctx, userStory)-> + $rs.userstories.getByRef(userStory.project, userStory.ref, {}).then (us) -> + scope.item.epics = us.epics + updateEpics(us.epics) + + scope.removeEpicRelationship = (epic) -> + title = $translate.instant("LIGHTBOX.REMOVE_RELATIONSHIP_WITH_EPIC.TITLE") + message = $translate.instant( + "LIGHTBOX.REMOVE_RELATIONSHIP_WITH_EPIC.MESSAGE", + { epicSubject: epic.get('subject') } + ) + + $confirm.askOnDelete(title, message).then (askResponse) -> + onSuccess = -> + askResponse.finish() + scope.$broadcast("related-epics:changed", scope.item) + + onError = -> + askResponse.finish(false) + $confirm.notify("error") + + epicId = epic.get('id') + usId = scope.item.id + $rs2.epics.deleteRelatedUserstory(epicId, usId).then(onSuccess, onError) + + updateEpics = (epics) -> + scope.epicsLength = 0 + scope.immutable_epics = [] if epics && !epics.isIterable - scope.immutable_epics = Immutable.fromJS(epics) + scope.epicsLength = epics.length + scope.immutable_epics = Immutable.fromJS(epics) templateUrl = (el, attrs) -> if attrs.format @@ -34,10 +65,13 @@ BelongToEpicsDirective = () -> return { link: link, scope: { - epics: '=' + epics: '=', + item: "=" }, templateUrl: templateUrl } -module.directive("tgBelongToEpics", BelongToEpicsDirective) +module.directive("tgBelongToEpics", [ + "$translate", "$tgConfirm", "$tgResources", "tgResources", "lightboxService", + BelongToEpicsDirective]) diff --git a/app/modules/components/belong-to-epics/belong-to-epics.scss b/app/modules/components/belong-to-epics/belong-to-epics.scss index 01e05872..831b2994 100644 --- a/app/modules/components/belong-to-epics/belong-to-epics.scss +++ b/app/modules/components/belong-to-epics/belong-to-epics.scss @@ -1,13 +1,57 @@ .belong-to-epic-pill-wrapper { display: inline-block; position: relative; - &:hover { - .belong-to-epic-pill-data { - display: block; + &:hover .belong-to-epic-pill-data { + display: block; + } +} + +.belong-to-epics-list { + margin-bottom: .5rem; + svg { + position: relative; + top: .1rem; + } + &.unique { + display: inline-block; + li { + display: inline-block; + svg { + top: .2rem; + } } } } +.belong-to-epic-text-wrapper { + display: flex; + flex-basis: 25%; + flex-wrap: wrap; + justify-content: flex-start; + padding-top: .25rem; + .remove-epic-relationship { + display: inline-block; + line-height: .5rem; + margin-left: .5rem; + svg { + visibility: hidden; + } + } + &:hover .remove-epic-relationship svg { + visibility: visible; + } +} + +.belong-to-epic-label { + @include font-type(light); + @include font-size(xsmall); + background: $grayer; + border-radius: .25rem; + color: $white; + margin: 0 .5rem; + padding: .1rem .25rem; +} + .belong-to-epic-pill { background-color: $mass-white; border-radius: 50%; @@ -17,20 +61,3 @@ position: relative; width: .7rem; } - -.belong-to-epic-text-wrapper { - margin-right: 1rem; -} - -.belong-to-epic-text { - margin-left: .25rem; -} -.belong-to-epic-label { - @include font-type(light); - @include font-size(xsmall); - background: $grayer; - border-radius: .25rem; - color: $white; - margin: 0 .5rem; - padding: .1rem .25rem; -} diff --git a/app/modules/components/detail/header/detail-header.controller.coffee b/app/modules/components/detail/header/detail-header.controller.coffee index 6702174e..11ab7f68 100644 --- a/app/modules/components/detail/header/detail-header.controller.coffee +++ b/app/modules/components/detail/header/detail-header.controller.coffee @@ -92,4 +92,7 @@ class StoryHeaderController return item return transform.then(onEditSubjectSuccess, onEditSubjectError) + relateToEpic: (us) -> + @rootScope.$broadcast("relate-to-epic:add", us) + module.controller("StoryHeaderCtrl", StoryHeaderController) diff --git a/app/modules/components/detail/header/detail-header.jade b/app/modules/components/detail/header/detail-header.jade index b254d1dc..d3c08bd1 100644 --- a/app/modules/components/detail/header/detail-header.jade +++ b/app/modules/components/detail/header/detail-header.jade @@ -43,14 +43,26 @@ ) //- User Story belongs to epic -.belong-to-epics-wrapper(ng-if="vm.item.epics") - span(translate="US.OWNER_US") - tg-belong-to-epics( - ng-if="::vm.item.epics" - epics="::vm.item.epics" - format="text" +.belong-to-epics-wrapper(ng-if="vm.item._name == 'userstories' && vm.project.is_epics_activated") + .related-to-epics + tg-belong-to-epics( + epics="::vm.item.epics" + item="::vm.item" + format="text" + ) + //- User Story relate to Epic + a.relate-to-epic-button.ng-animate-disabled( + ng-if="vm.permissions.canEdit" + href="" + title="{{'US.RELATE_TO_EPIC' | translate}}" + ng-click="vm.relateToEpic(vm.item)" ) - + tg-svg( + svg-icon="icon-epics" + svg-title-translate="US.RELATE_TO_EPIC" + ) + span.relate-to-epic-text(translate="US.RELATE_TO_EPIC") + //- Task belongs to US .task-belongs-to( ng-if="vm.item.user_story_extra_info" diff --git a/app/modules/components/detail/header/detail-header.scss b/app/modules/components/detail/header/detail-header.scss index 3ac3786a..83b7dee2 100644 --- a/app/modules/components/detail/header/detail-header.scss +++ b/app/modules/components/detail/header/detail-header.scss @@ -30,6 +30,22 @@ @include font-size(small); margin-top: .5rem; } + .relate-to-epic-button { + color: $gray-light; + cursor: pointer; + display: inline-block; + &:hover { + color: $primary-light; + } + .icon-epics { + @include svg-size(.9rem); + fill: currentColor; + margin: .5rem .25rem 0 0; + } + .relate-to-epic-text { + @include font-size(small); + } + } .item-generated-us, .task-belongs-to, .item-origin-issue, diff --git a/app/modules/epics/epics.service.coffee b/app/modules/epics/epics.service.coffee index 6c8c521c..c1ae32c6 100644 --- a/app/modules/epics/epics.service.coffee +++ b/app/modules/epics/epics.service.coffee @@ -64,15 +64,23 @@ class EpicsService listRelatedUserStories: (epic) -> return @resources.userstories.listInEpic(epic.get('id')) - createEpic: (epicData, attachments) -> - epicData.project = @projectService.project.get('id') + createEpic: (epicData, attachments, projectId) -> + if projectId + epicData.project = projectId + else + epicData.project = @projectService.project.get('id') return @resources.epics.post(epicData) .then (epic) => - promises = _.map attachments.toJS(), (attachment) => - @attachmentsService.upload(attachment.file, epic.get('id'), epic.get('project'), 'epic') + if !attachments + return epic + else + promises = _.map attachments.toJS(), (attachment) => + @attachmentsService.upload( + attachment.file, epic.get('id'), epic.get('project'), 'epic') + + Promise.all(promises).then(@.fetchEpics.bind(this, true)) - Promise.all(promises).then(@.fetchEpics.bind(this, true)) reorderEpic: (epic, newIndex) -> orderList = {} diff --git a/app/partials/common/lightbox/lightbox-relate-to-epic.jade b/app/partials/common/lightbox/lightbox-relate-to-epic.jade new file mode 100644 index 00000000..af10ee0f --- /dev/null +++ b/app/partials/common/lightbox/lightbox-relate-to-epic.jade @@ -0,0 +1,106 @@ +tg-lightbox-close + +.lightbox-create-related-epic-wrapper + h2.title(translate="LIGHTBOX.RELATE_TO_EPIC.TITLE") + + .related-with-selector(tg-check-permission="add_epic") + .related-with-selector-single + input( + type="radio" + name="related-with-selector" + id="existing-epic" + value="existing-epic" + ng-model="relatedWithSelector" + ng-init="relatedWithSelector='existing-epic'" + ) + label.e2e-existing-epic-label(for="existing-epic") + span.name {{ 'LIGHTBOX.RELATE_TO_EPIC.EXISTING_EPIC' | translate}} + + .related-with-selector-single + input( + type="radio" + name="related-with-selector" + id="new-epic" + value="new-epic" + ng-model="relatedWithSelector" + ) + label.e2e-new-epic-label(for="new-epic") + span.name {{ 'LIGHTBOX.RELATE_TO_EPIC.NEW_EPIC' | translate}} + + fieldset.project-selector + label( + ng-if="relatedWithSelector=='new-epic'" + translate="LIGHTBOX.RELATE_TO_EPIC.CHOOSE_PROJECT_FOR_CREATION" + for="project-selector-dropdown" + ) + label( + ng-if="relatedWithSelector=='existing-epic'" + translate="LIGHTBOX.RELATE_TO_EPIC.CHOOSE_PROJECT_FROM" + for="project-selector-dropdown" + ) + select( + ng-model="selectedProject" + ng-change="selectProject(selectedProject)" + data-required="true" + ng-options="p.id as p.name for p in projects | toMutable" + id="project-selector-dropdown" + ) + + fieldset(ng-show="relatedWithSelector=='new-epic'") + .new-epic-title + label(translate="LIGHTBOX.RELATE_TO_EPIC.SUBJECT") + form.new-epic-form + .single-creation + input.e2e-new-epic-input-text( + type="text" + ng-model="epicSubject" + data-required="true" + ) + + button.button-green.create-epic.e2e-create-epic-button.ng-animate-disabled( + href="" + ng-click="createEpic(selectedProject, epicSubject)" + tg-loading="loading" + translate="COMMON.SAVE" + ) + + fieldset.existing-epic(ng-show="relatedWithSelector=='existing-epic'") + label( + translate="LIGHTBOX.RELATE_TO_EPIC.CHOOSE_EPIC" + for="epic-filter" + ) + input.epic-filter.e2e-filter-userstories-input( + id="epic-filter" + type="text" + placeholder="{{'LIGHTBOX.RELATE_TO_EPIC.FILTER_EPICS' | translate}}" + ng-model="searchEpic" + ng-change="onUpdateSearchEpic()" + ) + + form.existing-epic-form(ng-show="relatedWithSelector=='existing-epic' && projectEpics.size") + select.epic.e2e-userstories-select( + size="5" + ng-model="selectedEpic" + data-required="true" + ) + - var hash = "#"; + option.hidden( + value="" + ) + option( + ng-repeat="epic in projectEpics | toMutable track by epic.id" + value="{{ ::epic.id }}" + ) #{hash}{{::epic.ref}} {{::epic.subject}} + + p.no-stories-found( + ng-show="relatedWithSelector=='existing-epic' && !projectEpics.size" + translate="LIGHTBOX.RELATE_TO_EPIC.NO_EPICS_FOUND" + ) + + button.button-green.e2e-select-related-epic-button( + href="" + ng-click="saveRelatedEpic(selectedEpic, closeLightbox)" + tg-loading="loading" + translate="COMMON.SAVE" + ) + diff --git a/app/partials/us/us-detail.jade b/app/partials/us/us-detail.jade index ce32b7e6..18effa14 100644 --- a/app/partials/us/us-detail.jade +++ b/app/partials/us/us-detail.jade @@ -156,3 +156,4 @@ div.wrapper( div.lightbox.lightbox-select-user(tg-lb-assignedto) div.lightbox.lightbox-select-user(tg-lb-assigned-users) div.lightbox.lightbox-select-user(tg-lb-watchers) + div.lightbox.lightbox-relate-to-epic(tg-lb-relatetoepic) diff --git a/app/styles/modules/common/lightbox.scss b/app/styles/modules/common/lightbox.scss index a0e68390..df160393 100644 --- a/app/styles/modules/common/lightbox.scss +++ b/app/styles/modules/common/lightbox.scss @@ -854,7 +854,6 @@ } } - .ticket-detail-settings .lightbox-assign-sprint-to-issue { .lightbox-assign-related-sprint { width: 700px; @@ -883,6 +882,92 @@ button { width: 100%; } - - +} + + +.lightbox-relate-to-epic { + .lightbox-create-related-epic-wrapper { + max-width: 600px; + width: 90%; + } + .related-with-selector { + display: flex; + margin-bottom: 1rem; + input { + display: none; + &:checked+label { + background: $primary-light; + color: $white; + transition: background .2s ease-in; + } + &:checked+label:hover { + background: $primary-light; + } + +label { + background: rgba($whitish, .7); + cursor: pointer; + display: block; + padding: 2rem 1rem; + text-align: center; + text-transform: uppercase; + transition: background .2s ease-in; + } + +label:hover { + background: rgba($primary-light, .3); + transition: background .2s ease-in; + } + } + .related-with-selector-single { + flex: 1; + &:first-child { + margin-right: .5rem; + } + } + } + fieldset { + label { + display: inline-block; + margin-bottom: .5rem; + } + } + .new-epic-title { + align-items: flex-end; + display: flex; + } + .existing-epic-form, + .new-epic-form { + margin-bottom: 1rem; + } + .no-epics-found { + padding: 1rem 0 0; + } + .new-epic-options { + display: flex; + margin-left: auto; + input { + display: none; + &:checked+label { + background: $primary-light; + color: $white; + fill: $white; + transition: background .2s ease-in; + } + +label { + background: $mass-white; + color: $grayer; + cursor: pointer; + display: block; + padding: .5rem; + transition: background .2s ease-in; + } + +label:hover { + background: $primary-light; + color: $white; + fill: $white; + } + } + } + button { + width: 100%; + } }