diff --git a/CHANGELOG.md b/CHANGELOG.md index 544ee7c3..c3e840dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 3.3.0 Picea mariana (2018-04-26) + +### Features + +- Add "live notifications" to Taiga: + - Add configuration in profile area. +- Add "due date" in US, Tasks and Issues. +- Add multiple assignement only in US. +- Delete cards in Kanban and sprint Taskboard. + ## 3.2.3 (2018-04-04) ### Misc diff --git a/app/coffee/app.coffee b/app/coffee/app.coffee index 584a4e8b..8a4cb88d 100644 --- a/app/coffee/app.coffee +++ b/app/coffee/app.coffee @@ -451,6 +451,8 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven {templateUrl: "user/user-change-password.html"}) $routeProvider.when("/user-settings/mail-notifications", {templateUrl: "user/mail-notifications.html"}) + $routeProvider.when("/user-settings/live-notifications", + {templateUrl: "user/live-notifications.html"}) $routeProvider.when("/change-email/:email_token", {templateUrl: "user/change-email.html"}) $routeProvider.when("/cancel-account/:cancel_token", diff --git a/app/coffee/modules/base.coffee b/app/coffee/modules/base.coffee index ce0ecdca..c1cde42c 100644 --- a/app/coffee/modules/base.coffee +++ b/app/coffee/modules/base.coffee @@ -124,6 +124,7 @@ urls = { "user-settings-user-change-password": "/user-settings/user-change-password" "user-settings-user-avatar": "/user-settings/user-avatar" "user-settings-mail-notifications": "/user-settings/mail-notifications" + "user-settings-live-notifications": "/user-settings/live-notifications" "user-settings-contrib": "/user-settings/contrib/:plugin" } diff --git a/app/coffee/modules/common.coffee b/app/coffee/modules/common.coffee index 5641fd80..d9e42386 100644 --- a/app/coffee/modules/common.coffee +++ b/app/coffee/modules/common.coffee @@ -292,7 +292,6 @@ class QueueModelTransformation extends taiga.Service save: (transformation) -> defered = @q.defer() - @qqueue.add () => obj = @.getObj() comment = obj.comment diff --git a/app/coffee/modules/common/components.coffee b/app/coffee/modules/common/components.coffee index e043c05a..238a31d7 100644 --- a/app/coffee/modules/common/components.coffee +++ b/app/coffee/modules/common/components.coffee @@ -230,7 +230,6 @@ WatchersDirective = ($rootscope, $confirm, $repo, $modelTransform, $template, $c watchers = _.map(watchers, (watcherId) -> $scope.usersById[watcherId]) renderWatchers(watchers) $rootscope.$broadcast("object:updated") - transform.then null, -> $confirm.notify("error") @@ -299,6 +298,129 @@ module.directive("tgWatchers", ["$rootScope", "$tgConfirm", "$tgRepo", "$tgQueue "$translate", WatchersDirective]) + +############################################################################# +## Assigned Users directive +############################################################################# + +AssignedUsersDirective = ($rootscope, $confirm, $repo, $modelTransform, $template, $compile, $translate, $currentUserService) -> + # You have to include a div with the tg-lb-assignedusers directive in the page + # where use this directive + + link = ($scope, $el, $attrs, $model) -> + isEditable = -> + return $scope.project?.my_permissions?.indexOf($attrs.requiredPerm) != -1 + isAssigned = -> + return $scope.assignedUsers.length > 0 + + save = (assignedUsers, assignedToUser) -> + transform = $modelTransform.save (item) -> + item.assigned_users = assignedUsers + if not item.assigned_to + item.assigned_to = assignedToUser + return item + + transform.then -> + assignedUsers = _.map(assignedUsers, (assignedUserId) -> $scope.usersById[assignedUserId]) + renderAssignedUsers(assignedUsers) + result = $rootscope.$broadcast("object:updated") + + transform.then null, -> + $confirm.notify("error") + + openAssignedUsers = -> + item = _.clone($model.$modelValue, false) + $rootscope.$broadcast("assigned-user:add", item) + + assignToMe = -> + return if not isEditable() + currentUserId = $currentUserService.getUser().get('id') + assignedUsers = _.clone($model.$modelValue.assigned_users, false) + assignedUsers.push(currentUserId) + assignedUsers = _.uniq(assignedUsers) + save(assignedUsers, currentUserId) + + deleteAssignedUser = (assignedUserIds) -> + transform = $modelTransform.save (item) -> + item.assigned_users = assignedUserIds + + # Update as + if item.assigned_to not in assignedUserIds and assignedUserIds.length > 0 + item.assigned_to = assignedUserIds[0] + if assignedUserIds.length == 0 + item.assigned_to = null + + return item + + transform.then () -> + item = $modelTransform.getObj() + assignedUsers = _.map(item.assignedUsers, (assignedUserId) -> $scope.usersById[assignedUserId]) + renderAssignedUsers(assignedUsers) + $rootscope.$broadcast("object:updated") + + transform.then null, -> + item.revert() + $confirm.notify("error") + + renderAssignedUsers = (assignedUsers) -> + $scope.assignedUsers = assignedUsers + $scope.isEditable = isEditable() + $scope.isAssigned = isAssigned() + $scope.openAssignedUsers = openAssignedUsers + $scope.assignToMe = assignToMe + + $el.on "click", ".remove-user", (event) -> + event.preventDefault() + return if not isEditable() + target = angular.element(event.currentTarget) + assignedUserId = target.data("assigned-user-id") + + title = $translate.instant("COMMON.ASSIGNED_USERS.TITLE_LIGHTBOX_DELETE_ASSIGNED") + message = $scope.usersById[assignedUserId].full_name_display + + $confirm.askOnDelete(title, message).then (askResponse) => + askResponse.finish() + + assignedUserIds = _.clone($model.$modelValue.assigned_users, false) + assignedUserIds = _.pull(assignedUserIds, assignedUserId) + + deleteAssignedUser(assignedUserIds) + + $scope.$on "assigned-user:deleted", (ctx, assignedUserId) -> + assignedUsersIds = _.clone($model.$modelValue.assigned_users, false) + assignedUsersIds = _.pull(assignedUsersIds, assignedUserId) + assignedUsersIds = _.uniq(assignedUsersIds) + deleteAssignedUser(assignedUsersIds) + + $scope.$on "assigned-user:added", (ctx, assignedUserId) -> + assignedUsers = _.clone($model.$modelValue.assigned_users, false) + assignedUsers.push(assignedUserId) + assignedUsers = _.uniq(assignedUsers) + + # Save assigned_users and assignedUserId for assign_to legacy attribute + save(assignedUsers, assignedUserId) + + $scope.$watch $attrs.ngModel, (item) -> + return if not item? + assignedUsers = _.map(item.assigned_users, (assignedUserId) -> $scope.usersById[assignedUserId]) + assignedUsers = _.filter assignedUsers, (it) -> return !!it + + renderAssignedUsers(assignedUsers) + + $scope.$on "$destroy", -> + $el.off() + + return { + scope: true, + templateUrl: "common/components/assigned-users.html", + link:link, + require:"ngModel" + } + +module.directive("tgAssignedUsers", ["$rootScope", "$tgConfirm", "$tgRepo", "$tgQueueModelTransformation", "$tgTemplate", "$compile", + "$translate", "tgCurrentUserService", AssignedUsersDirective]) + + ############################################################################# ## Assigned to directive ############################################################################# @@ -386,7 +508,6 @@ AssignedToDirective = ($rootscope, $confirm, $repo, $loading, $modelTransform, $ $scope.$on "assigned-to:added", (ctx, userId, item) -> return if item.id != $model.$modelValue.id - save(userId) $scope.$watch $attrs.ngModel, (instance) -> diff --git a/app/coffee/modules/common/confirm.coffee b/app/coffee/modules/common/confirm.coffee index 67227343..af2948c8 100644 --- a/app/coffee/modules/common/confirm.coffee +++ b/app/coffee/modules/common/confirm.coffee @@ -86,8 +86,10 @@ class ConfirmService extends taiga.Service return defered.promise - askOnDelete: (title, message) -> - return @.ask(title, @translate.instant("NOTIFICATION.ASK_DELETE"), message) + askOnDelete: (title, message, subtitle) -> + if not subtitle? + subtitle = @translate.instant("NOTIFICATION.ASK_DELETE") + return @.ask(title, subtitle, message) askChoice: (title, subtitle, choices, replacement, warning, lightboxSelector=".lightbox-ask-choice") -> defered = @q.defer() diff --git a/app/coffee/modules/common/lightboxes.coffee b/app/coffee/modules/common/lightboxes.coffee index 9ff7e739..8b203d32 100644 --- a/app/coffee/modules/common/lightboxes.coffee +++ b/app/coffee/modules/common/lightboxes.coffee @@ -54,10 +54,10 @@ class LightboxService extends taiga.Service @animationFrame.add -> $el.addClass("open") $el.one "transitionend", => - firstField = $el.find('input,textarea').first() + firstField = $el.find('input:not(.no-focus),textarea:not(.no-focus)').first() if firstField.length - $el.find('input,textarea').first().focus() + firstField.focus() else if document.activeElement $(document.activeElement).blur() @@ -646,7 +646,6 @@ AssignedToLightboxDirective = (lightboxService, lightboxKeyboardNavigationServic selectedItem = item assignedToId = item.assigned_to selectedUser = $scope.usersById[assignedToId] - render(selectedUser) lightboxService.open($el).then -> $el.find('input').focus() @@ -693,10 +692,121 @@ AssignedToLightboxDirective = (lightboxService, lightboxKeyboardNavigationServic link:link } - module.directive("tgLbAssignedto", ["lightboxService", "lightboxKeyboardNavigationService", "$tgTemplate", "$compile", "tgAvatarService", AssignedToLightboxDirective]) +############################################################################# +## Assigned Users Lightbox directive +############################################################################# + +AssignedUsersLightboxDirective = ($repo, lightboxService, lightboxKeyboardNavigationService, $template, $compile, avatarService) -> + link = ($scope, $el, $attrs) -> + selectedUsers = [] + selectedItem = null + usersTemplate = $template.get("common/lightbox/lightbox-assigned-users-users.html", true) + + normalizeString = (string) -> + normalizedString = string + normalizedString = normalizedString.replace("Á", "A").replace("Ä", "A").replace("À", "A") + normalizedString = normalizedString.replace("É", "E").replace("Ë", "E").replace("È", "E") + normalizedString = normalizedString.replace("Í", "I").replace("Ï", "I").replace("Ì", "I") + normalizedString = normalizedString.replace("Ó", "O").replace("Ö", "O").replace("Ò", "O") + normalizedString = normalizedString.replace("Ú", "U").replace("Ü", "U").replace("Ù", "U") + return normalizedString + + filterUsers = (text, user) -> + username = user.full_name_display.toUpperCase() + username = normalizeString(username) + text = text.toUpperCase() + text = normalizeString(text) + + return _.includes(username, text) + + # Render the specific list of users. + render = (assignedUsersIds, text) -> + users = _.clone($scope.activeUsers, true) + users = _.sortBy(users, (o) -> if o.id is $scope.user.id then 0 else o.id) + users = _.filter(users, _.partial(filterUsers, text)) if text? + + # Add selected users + selected = [] + _.map users, (user) -> + if user.id in assignedUsersIds + user.avatar = avatarService.getAvatar(user) + selected.push(user) + + # Filter users in searchs + + visible = [] + _.map users, (user) -> + if user.id not in assignedUsersIds + user.avatar = avatarService.getAvatar(user) + visible.push(user) + + ctx = { + selected: selected + users: _.slice(visible, 0, 5) + showMore: users.length > 5 + } + + html = usersTemplate(ctx) + html = $compile(html)($scope) + $el.find(".assigned-to-list").html(html) + + closeLightbox = () -> + lightboxKeyboardNavigationService.stop() + lightboxService.close($el) + + $scope.$on "assigned-user:add", (ctx, item) -> + selectedItem = item + selectedUsers = item.assigned_users + render(selectedUsers) + + lightboxService.open($el).then -> + $el.find("input").focus() + lightboxKeyboardNavigationService.init($el) + + $scope.$watch "usersSearch", (searchingText) -> + if searchingText? + render(selectedUsers, searchingText) + $el.find('input').focus() + + $el.on "click", ".user-list-single", debounce 200, (event) -> + closeLightbox() + + event.preventDefault() + target = angular.element(event.currentTarget) + + $scope.$apply -> + $scope.usersSearch = null + $scope.$broadcast("assigned-user:added", target.data("user-id"), selectedItem) + + $el.on "click", ".remove-assigned-to", (event) -> + event.preventDefault() + target = angular.element(event.currentTarget) + event.stopPropagation() + + $scope.$apply -> + $scope.usersSearch = null + $scope.$broadcast("assigned-user:deleted", target.data("user-id"), selectedItem) + closeLightbox() + + $el.on "click", ".close", (event) -> + event.preventDefault() + + closeLightbox() + + $scope.$on "$destroy", -> + $el.off() + + return { + templateUrl: "common/lightbox/lightbox-assigned-users.html" + link:link + } + +module.directive("tgLbAssignedUsers", ["$tgRepo", "lightboxService", "lightboxKeyboardNavigationService", "$tgTemplate", "$compile", "tgAvatarService", AssignedUsersLightboxDirective]) + + ############################################################################# ## Watchers Lightbox directive ############################################################################# @@ -802,3 +912,73 @@ LightboxLeaveProjectWarningDirective = (lightboxService, $template, $compile) -> } module.directive("tgLightboxLeaveProjectWarning", ["lightboxService", LightboxLeaveProjectWarningDirective]) + + +############################################################################# +## Set Due Date Lightbox Directive +############################################################################# + +SetDueDateDirective = (lightboxService, $loading, $translate, $confirm, $modelTransform) -> + link = ($scope, $el, attrs) -> + prettyDate = $translate.instant("COMMON.PICKERDATE.FORMAT") + lightboxService.open($el) + + if ($scope.object.due_date) + $scope.new_due_date = moment($scope.object.due_date).format(prettyDate) + + $el.on "click", ".suggestion", (event) -> + target = angular.element(event.currentTarget) + quantity = target.data('quantity') + unit = target.data('unit') + value = moment().add(quantity, unit).format(prettyDate) + $el.find(".due-date").val(value) + + save = -> + currentLoading = $loading() + .target($el.find(".submit-button")) + .start() + + transform = $modelTransform.save (object) -> + new_due_date = $('.due-date').val() + object.due_date = if (new_due_date) \ + then moment(new_due_date, prettyDate).format("YYYY-MM-DD") \ + else null + return object + + transform.then -> + $confirm.notify("success") + + transform.then null, -> + $confirm.notify("error") + + transform.finally -> + currentLoading.finish() + lightboxService.close($el) + + $el.on "click", ".submit-button", (event) -> + event.preventDefault() + save() + + remove = -> + title = $translate.instant("LIGHTBOX.DELETE_DUE_DATE.TITLE") + subtitle = $translate.instant("LIGHTBOX.DELETE_DUE_DATE.SUBTITLE") + message = moment($scope.object.due_date).format(prettyDate) + + $confirm.askOnDelete(title, message, subtitle).then (askResponse) -> + askResponse.finish() + $('.due-date').val(null) + $scope.object.due_date_reason = null + save() + + $el.on "click", ".delete-due-date", (event) -> + event.preventDefault() + remove() + + return { + templateUrl: 'common/lightbox/lightbox-due-date.html', + link: link, + scope: true + } + +module.directive("tgLbSetDueDate", ["lightboxService", "$tgLoading", "$translate", "$tgConfirm" + "$tgQueueModelTransformation", SetDueDateDirective]) diff --git a/app/coffee/modules/events.coffee b/app/coffee/modules/events.coffee index 6202289b..b2218d84 100644 --- a/app/coffee/modules/events.coffee +++ b/app/coffee/modules/events.coffee @@ -87,6 +87,37 @@ class EventsService @liveAnnouncementService.show(data.title, data.desc) @rootScope.$digest() + liveNotifications: -> + if not @.auth.userData? + return + userId = @.auth.userData.get('id') + + subscribe = () => + @.subscribe null, "live_notifications.#{userId}", (data) => + notification = new Notification(data.title, { + icon: "/#{window._version}/images/favicon.png", + body: data.body, + tag: data.id + }) + notification.onshow = () => + if data.timeout and data.timeout > 0 + setTimeout => + notification.close() + , + data.timeout + + if data.url + notification.onclick = () => + window.open data.url + if !Notification + console.log("This browser does not support desktop notification") + else if Notification.permission == "granted" + subscribe() + else if Notification.permission != 'denied' + Notification.requestPermission (permission) => + if (permission == "granted") + subscribe() + ########################################### # Heartbeat (Ping - Pong) ########################################### @@ -216,6 +247,7 @@ class EventsService @.sendMessage(message) @.startHeartBeatMessages() @.notifications() + @.liveNotifications() onMessage: (event) -> @.log.debug "WebSocket message received: #{event.data}" diff --git a/app/coffee/modules/issues/detail.coffee b/app/coffee/modules/issues/detail.coffee index f74333e0..8137ef04 100644 --- a/app/coffee/modules/issues/detail.coffee +++ b/app/coffee/modules/issues/detail.coffee @@ -625,6 +625,7 @@ PromoteIssueToUsButtonDirective = ($rootScope, $repo, $confirm, $translate) -> tags: issue.tags is_blocked: issue.is_blocked blocked_note: issue.blocked_note + due_date: issue.due_date } onSuccess = -> diff --git a/app/coffee/modules/kanban/main.coffee b/app/coffee/modules/kanban/main.coffee index 697f7ad8..da1ca14b 100644 --- a/app/coffee/modules/kanban/main.coffee +++ b/app/coffee/modules/kanban/main.coffee @@ -151,7 +151,12 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi @.refreshTagsColors().then () => @kanbanUserstoriesService.replaceModel(us) + @scope.$on "kanban:us:deleted", (event, us) => + @.filtersReloadContent() + @scope.$on("assigned-to:added", @.onAssignedToChanged) + @scope.$on("assigned-user:added", @.onAssignedUsersChanged) + @scope.$on("assigned-user:deleted", @.onAssignedUsersDeleted) @scope.$on("kanban:us:move", @.moveUs) @scope.$on("kanban:show-userstories-for-status", @.loadUserStoriesForStatus) @scope.$on("kanban:hide-userstories-for-status", @.hideUserStoriesForStatus) @@ -165,7 +170,7 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi editUs: (id) -> us = @kanbanUserstoriesService.getUs(id) - us = us.set('loading', true) + us = us.set('loading-edit', true) @kanbanUserstoriesService.replace(us) @rs.userstories.getByRef(us.getIn(['model', 'project']), us.getIn(['model', 'ref'])) @@ -173,9 +178,27 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi @rs2.attachments.list("us", us.get('id'), us.getIn(['model', 'project'])).then (attachments) => @rootscope.$broadcast("usform:edit", editingUserStory, attachments.toJS()) - us = us.set('loading', false) + us = us.set('loading-edit', false) @kanbanUserstoriesService.replace(us) + deleteUs: (id) -> + us = @kanbanUserstoriesService.getUs(id) + us = us.set('loading-delete', true) + + @rs.userstories.getByRef(us.getIn(['model', 'project']), us.getIn(['model', 'ref'])) + .then (deletingUserStory) => + us = us.set('loading-delete', false) + title = @translate.instant("US.TITLE_DELETE_ACTION") + message = deletingUserStory.subject + @confirm.askOnDelete(title, message).then (askResponse) => + promise = @repo.remove(deletingUserStory) + promise.then => + @scope.$broadcast("kanban:us:deleted") + askResponse.finish() + promise.then null, -> + askResponse.finish(false) + @confirm.notify("error") + showPlaceHolder: (statusId) -> if @scope.usStatusList[0].id == statusId && !@kanbanUserstoriesService.userstoriesRaw.length @@ -194,6 +217,10 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi @rootscope.$broadcast("assigned-to:add", us) + changeUsAssignedUsers: (id) -> + us = @kanbanUserstoriesService.getUsModel(id) + @rootscope.$broadcast("assigned-user:add", us) + onAssignedToChanged: (ctx, userid, usModel) -> usModel.assigned_to = userid @@ -204,6 +231,37 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi if @.isFilterDataTypeSelected('assigned_to') || @.isFilterDataTypeSelected('role') @.filtersReloadContent() + onAssignedUsersChanged: (ctx, userid, usModel) -> + assignedUsers = _.clone(usModel.assigned_users, false) + assignedUsers.push(userid) + assignedUsers = _.uniq(assignedUsers) + usModel.assigned_users = assignedUsers + if not usModel.assigned_to + usModel.assigned_to = userid + @kanbanUserstoriesService.replaceModel(usModel) + + promise = @repo.save(usModel) + promise.then null, -> + console.log "FAIL" # TODO + + onAssignedUsersDeleted: (ctx, userid, usModel) -> + assignedUsersIds = _.clone(usModel.assigned_users, false) + assignedUsersIds = _.pull(assignedUsersIds, userid) + assignedUsersIds = _.uniq(assignedUsersIds) + usModel.assigned_users = assignedUsersIds + + # Update as + if usModel.assigned_to not in assignedUsersIds and assignedUsersIds.length > 0 + usModel.assigned_to = assignedUsersIds[0] + if assignedUsersIds.length == 0 + usModel.assigned_to = null + + @kanbanUserstoriesService.replaceModel(usModel) + + promise = @repo.save(usModel) + promise.then null, -> + console.log "FAIL" # TODO + refreshTagsColors: -> return @rs.projects.tagsColors(@scope.projectId).then (tags_colors) => @scope.project.tags_colors = tags_colors._attrs diff --git a/app/coffee/modules/taskboard/main.coffee b/app/coffee/modules/taskboard/main.coffee index 8697bf61..6f918f30 100644 --- a/app/coffee/modules/taskboard/main.coffee +++ b/app/coffee/modules/taskboard/main.coffee @@ -308,6 +308,9 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin, taiga @.refreshTagsColors().then () => @taskboardTasksService.replaceModel(task) + @scope.$on "taskboard:task:deleted", (event, task) => + @.loadTasks() + @scope.$on("taskboard:task:move", @.taskMove) @scope.$on("assigned-to:added", @.onAssignedToChanged) @@ -432,7 +435,7 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin, taiga editTask: (id) -> task = @.taskboardTasksService.getTask(id) - task = task.set('loading', true) + task = task.set('loading-edit', true) @taskboardTasksService.replace(task) @rs.tasks.getByRef(task.getIn(['model', 'project']), task.getIn(['model', 'ref'])).then (editingTask) => @@ -441,6 +444,25 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin, taiga task = task.set('loading', false) @taskboardTasksService.replace(task) + deleteTask: (id) -> + task = @.taskboardTasksService.getTask(id) + task = task.set('loading-delete', true) + + @rs.tasks.getByRef(task.getIn(['model', 'project']), task.getIn(['model', 'ref'])) + .then (deletingTask) => + task = task.set('loading-delete', false) + title = @translate.instant("TASK.TITLE_DELETE_ACTION") + message = deletingTask.subject + @confirm.askOnDelete(title, message).then (askResponse) => + promise = @repo.remove(deletingTask) + promise.then => + @scope.$broadcast("taskboard:task:deleted") + askResponse.finish() + promise.then null, -> + askResponse.finish(false) + @confirm.notify("error") + + taskMove: (ctx, task, oldStatusId, usId, statusId, order) -> task = @taskboardTasksService.getTaskModel(task.get('id')) diff --git a/app/coffee/modules/user-settings/live-notifications.coffee b/app/coffee/modules/user-settings/live-notifications.coffee new file mode 100644 index 00000000..7411dd46 --- /dev/null +++ b/app/coffee/modules/user-settings/live-notifications.coffee @@ -0,0 +1,155 @@ +### +# Copyright (C) 2014-2017 Andrey Antukh +# Copyright (C) 2014-2017 Jesús Espino Garcia +# Copyright (C) 2014-2017 David Barragán Merino +# Copyright (C) 2014-2017 Alejandro Alonso +# Copyright (C) 2014-2017 Juan Francisco Alcántara +# Copyright (C) 2014-2017 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/user-settings/live-notifications.coffee +### + +taiga = @.taiga +mixOf = @.taiga.mixOf +bindOnce = @.taiga.bindOnce + +module = angular.module("taigaUserSettings") + + +############################################################################# +## User settings Controller +############################################################################# + +class UserLiveNotificationsController extends mixOf(taiga.Controller, taiga.PageMixin) + @.$inject = [ + "$scope", + "$rootScope", + "$tgRepo", + "$tgConfirm", + "$tgResources", + "$routeParams", + "$q", + "$tgLocation", + "$tgNavUrls", + "$tgAuth", + "tgErrorHandlingService" + ] + + constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @location, @navUrls, @auth, @errorHandlingService) -> + @scope.sectionName = "USER_SETTINGS.NOTIFICATIONS.LIVE_SECTION_NAME" + @scope.user = @auth.getUser() + promise = @.loadInitialData() + promise.then null, @.onInitialDataError.bind(@) + + loadInitialData: -> + return @rs.notifyPolicies.list().then (notifyPolicies) => + @scope.notifyPolicies = notifyPolicies + return notifyPolicies + +module.controller("UserLiveNotificationsController", UserLiveNotificationsController) + + +############################################################################# +## User Notifications Directive +############################################################################# + +UserLiveNotificationsDirective = () -> + link = ($scope, $el, $attrs) -> + $scope.$on "$destroy", -> + $el.off() + + return {link:link} + +module.directive("tgUserLiveNotifications", UserLiveNotificationsDirective) + + +############################################################################# +## User Notifications List Directive +############################################################################# + +UserLiveNotificationsListDirective = ($repo, $confirm, $compile) -> + template = _.template(""" + <% _.each(notifyPolicies, function (notifyPolicy, index) { %> +
+
<%- notifyPolicy.project_name %>
+
+
+ checked="checked"<% } %>/> + +
+
+
+
+ checked="checked"<% } %> /> + +
+
+
+
+ checked="checked"<% } %> /> + +
+
+
+ <% }) %> + """) + + link = ($scope, $el, $attrs) -> + render = -> + $el.off() + + ctx = {notifyPolicies: $scope.notifyPolicies} + html = template(ctx) + + $el.html($compile(html)($scope)) + + $el.on "change", "input[type=radio]", (event) -> + target = angular.element(event.currentTarget) + + policyIndex = target.parents(".policy-table-row").data('index') + policy = $scope.notifyPolicies[policyIndex] + prev_level = policy.live_notify_level + policy.live_notify_level = parseInt(target.val(), 10) + + onSuccess = -> + $confirm.notify("success") + + onError = -> + $confirm.notify("error") + target.parents(".policy-table-row") + .find("input[value=#{prev_level}]") + .prop("checked", true) + + $repo.save(policy).then(onSuccess, onError) + + $scope.$on "$destroy", -> + $el.off() + + bindOnce($scope, $attrs.ngModel, render) + + return {link:link} + +module.directive("tgUserLiveNotificationsList", ["$tgRepo", "$tgConfirm", "$compile", + UserLiveNotificationsListDirective]) diff --git a/app/locales/taiga/locale-en.json b/app/locales/taiga/locale-en.json index 9347aff7..1be1c5cb 100644 --- a/app/locales/taiga/locale-en.json +++ b/app/locales/taiga/locale-en.json @@ -47,7 +47,8 @@ "RELATED_USERSTORIES": "Related user stories", "CARD": { "ASSIGN_TO": "Assign To", - "EDIT": "Edit card" + "EDIT": "Edit card", + "DELETE": "Delete card" }, "FORM_ERRORS": { "DEFAULT_MESSAGE": "This value seems to be invalid.", @@ -143,11 +144,14 @@ "SEVERITY": "Severity", "PRIORITY": "Priority", "ASSIGNED_TO": "Assigned to", + "ASSIGNED_USERS": "Assigned users", "POINTS": "Points", "IS_BLOCKED": "is blocked", "REF": "Ref", "VOTES": "Votes", - "SPRINT": "Sprint" + "SPRINT": "Sprint", + "DUE_DATE": "Due date", + "DUE_DATE_REASON": "Due date reason" }, "ROLES": { "ALL": "All" @@ -162,6 +166,17 @@ "TITLE_ACTION_EDIT_ASSIGNMENT": "Edit assignment", "SELF": "Assign to me" }, + "DUE_DATE": { + "TITLE_ACTION_SET_DUE_DATE": "Set due date", + "DUE_SOON": "due soon", + "PAST_DUE": "past due", + "NO_LONGER_APPLICABLE": "no longer applicable" + }, + "ASSIGNED_USERS": { + "ADD": "Select assigned user", + "ADD_ASSIGNED": "Add assigned", + "TITLE_LIGHTBOX_DELETE_ASSIGNED": "Delete assigned..." + }, "STATUS": { "CLOSED": "Closed", "OPEN": "Open" @@ -1076,6 +1091,10 @@ "EDIT_US": "Edit user story", "CONFIRM_CLOSE": "You have not saved changes.\nAre you sure you want to close the form?" }, + "DELETE_DUE_DATE": { + "TITLE": "Delete due date", + "SUBTITLE": "Are you sure you want to delete this due date?" + }, "DELETE_SPRINT": { "TITLE": "Delete sprint" }, @@ -1106,6 +1125,19 @@ "WARNING": "The email will be received by the project admins", "PLACEHOLDER": "Write your message", "SEND": "Send" + }, + "SET_DUE_DATE": { + "TITLE": "Set due date", + "PLACEHOLDER_DUE_DATE": "Select date", + "REASON_FOR_DUE_DATE": "Reason for the due date", + "PLACEHOLDER_REASON_FOR_DUE_DATE": "Why does this US need a due date?", + "SUGGESTIONS": { + "IN_ONE_WEEK": "In one week", + "IN_TWO_WEEKS": "In two weeks", + "IN_ONE_MONTH": "In one month", + "IN_THREE_MONTHS": "In three months" + }, + "TITLE_ACTION_DELETE_DUE_DATE": "Delete due date" } }, "EPIC": { @@ -1194,6 +1226,7 @@ "CLIENT_REQUIREMENT": "Client Requirement", "BLOCKED": "Blocked", "VALUES": { + "NOT_SET": "not set", "UNASSIGNED": "unassigned" }, "FIELDS": { @@ -1202,6 +1235,8 @@ "STATUS": "status", "TYPE": "type", "ASSIGNED_TO": "assigned to", + "ASSIGNED_USERS": "assigned users", + "DUE_DATE": "due date", "MILESTONE": "sprint", "COLOR": "color" } @@ -1345,6 +1380,7 @@ "SAVED": "Our Oompa Loompas saved all your changes!", "CLOSE": "Close notification", "MAIL": "Notifications By Mail", + "DESKTOP": "Desktop notifications using browser alerts", "ASK_DELETE": "Are you sure you want to delete?" }, "CANCEL_ACCOUNT": { @@ -1460,9 +1496,11 @@ "SECTION_TITLE": "User Settings", "USER_PROFILE": "User profile", "CHANGE_PASSWORD": "Change password", - "EMAIL_NOTIFICATIONS": "Email notifications" + "EMAIL_NOTIFICATIONS": "Email notifications", + "DESKTOP_NOTIFICATIONS": "Desktop notifications" }, "NOTIFICATIONS": { + "LIVE_SECTION_NAME": "Desktop Notifications", "SECTION_NAME": "Email Notifications", "COLUMN_PROJECT": "Project", "COLUMN_RECEIVE_ALL": "Receive All", diff --git a/app/locales/taiga/locale-es.json b/app/locales/taiga/locale-es.json index 0792dcb2..076c88ed 100644 --- a/app/locales/taiga/locale-es.json +++ b/app/locales/taiga/locale-es.json @@ -1673,4 +1673,4 @@ "RESULTS": "Resultados de búsqueda" } } -} \ No newline at end of file +} diff --git a/app/modules/components/card/card-templates/card-data.jade b/app/modules/components/card/card-templates/card-data.jade index 73f941c2..7138f257 100644 --- a/app/modules/components/card/card-templates/card-data.jade +++ b/app/modules/components/card/card-templates/card-data.jade @@ -10,6 +10,11 @@ ng-if="vm.item.getIn(['model', 'total_points'])" ) {{"COMMON.FIELDS.POINTS" | translate}} {{vm.item.getIn(['model', 'total_points'])}} .card-statistics + tg-due-date.statistic.card-due-date( + due-date="vm.item.getIn(['model', 'due_date'])" + due-date-status="vm.item.getIn(['model', 'due_date_status'])" + is-closed="vm.item.getIn(['model', 'is_closed'])" + ) .statistic.card-iocaine( ng-if="vm.item.getIn(['model', 'is_iocaine'])" title="{{'COMMON.IOCAINE_TEXT' | translate}}" diff --git a/app/modules/components/card/card-templates/card-owner.jade b/app/modules/components/card/card-templates/card-owner.jade index 380df5f5..49789626 100644 --- a/app/modules/components/card/card-templates/card-owner.jade +++ b/app/modules/components/card/card-templates/card-owner.jade @@ -20,7 +20,7 @@ .card-owner-actions( ng-if="vm.visible('owner')" - tg-check-permission="{{vm.getPermissionsKey()}}" + tg-check-permission="{{vm.getModifyPermisionKey()}}" ) a.e2e-assign.card-owner-assign( ng-click="!$event.ctrlKey && !$event.metaKey && vm.onClickAssignedTo({id: vm.item.get('id')})" @@ -29,10 +29,20 @@ tg-svg(svg-icon="icon-add-user") span(translate="COMMON.CARD.ASSIGN_TO") - a.e2e-edit.card-edit( - href="" - ng-click="!$event.ctrlKey && !$event.metaKey && vm.onClickEdit({id: vm.item.get('id')})" - tg-loading="vm.item.get('loading')" - ) - tg-svg(svg-icon="icon-edit") - span(translate="COMMON.CARD.EDIT") + div.card-actions + a.e2e-edit.card-edit( + href="" + ng-click="!$event.ctrlKey && !$event.metaKey && vm.onClickEdit({id: vm.item.get('id')})" + tg-loading="vm.item.get('loading-edit')" + title="{{ 'COMMON.CARD.EDIT' | translate }}" + ) + tg-svg(svg-icon="icon-edit") + + a.e2e-edit.card-delete( + href="" + ng-click="!$event.ctrlKey && !$event.metaKey && vm.onClickDelete({id: vm.item.get('id')})" + tg-loading="vm.item.get('loading-delete')" + title="{{ 'COMMON.CARD.DELETE' | translate }}" + tg-check-permission="{{vm.getDeletePermisionKey()}}" + ) + tg-svg(svg-icon="icon-trash") diff --git a/app/modules/components/card/card.controller.coffee b/app/modules/components/card/card.controller.coffee index ffacf9b1..f264f85c 100644 --- a/app/modules/components/card/card.controller.coffee +++ b/app/modules/components/card/card.controller.coffee @@ -39,11 +39,11 @@ class CardController closedTasksPercent: () -> return @.getClosedTasks().size * 100 / @.item.getIn(['model', 'tasks']).size - getPermissionsKey: () -> - if @.type == 'task' - return 'modify_task' - else - return 'modify_us' + getModifyPermisionKey: () -> + return if @.type == 'task' then 'modify_task' else 'modify_us' + + getDeletePermisionKey: () -> + return if @.type == 'task' then 'delete_task' else 'delete_us' _setVisibility: () -> visibility = { diff --git a/app/modules/components/card/card.directive.coffee b/app/modules/components/card/card.directive.coffee index 4ad69505..87338ca0 100644 --- a/app/modules/components/card/card.directive.coffee +++ b/app/modules/components/card/card.directive.coffee @@ -31,6 +31,7 @@ cardDirective = () -> onToggleFold: "&", onClickAssignedTo: "&", onClickEdit: "&", + onClickDelete: "&", project: "=", item: "=", zoom: "=", diff --git a/app/modules/components/card/card.scss b/app/modules/components/card/card.scss index b751ead6..a66f5425 100644 --- a/app/modules/components/card/card.scss +++ b/app/modules/components/card/card.scss @@ -119,6 +119,14 @@ fill: currentColor; } } + .card-actions { + display: flex; + justify-content: space-between; + padding: 0 .5rem; + } + .card-delete:hover { + color: $red-light; + } .icon { @include svg-size(1.2rem); display: inline-block; @@ -129,7 +137,7 @@ align-items: center; cursor: pointer; display: flex; - padding: .6rem 1rem; + padding: .6rem .5rem; } } diff --git a/app/modules/components/due-date/due-date-button.jade b/app/modules/components/due-date/due-date-button.jade new file mode 100644 index 00000000..b55c0efb --- /dev/null +++ b/app/modules/components/due-date/due-date-button.jade @@ -0,0 +1,8 @@ +label.due-date-button.button-gray.is-editable( + ng-if="vm.visible()" + ng-disabled="vm.disabled()" + ng-class="vm.color()" + ng-attr-title="{{ vm.title() }}" + ng-click="vm.setDueDate()" +) + tg-svg(svg-icon="icon-clock") diff --git a/app/modules/components/due-date/due-date-controller.coffee b/app/modules/components/due-date/due-date-controller.coffee new file mode 100644 index 00000000..83740926 --- /dev/null +++ b/app/modules/components/due-date/due-date-controller.coffee @@ -0,0 +1,71 @@ +### +# Copyright (C) 2014-2018 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: due-date-controller.coffee +### + +class DueDateController + @.$inject = [ + "$translate" + "tgLightboxFactory" + ] + + constructor: (@translate, @tgLightboxFactory) -> + + visible: () -> + return @.format == 'button' or @.dueDate? + + disabled: () -> + return @.isClosed + + color: () -> + colors = { + 'no_longer_applicable': 'closed', + 'due_soon': 'due-soon', + 'past_due': 'past-due', + 'set': 'due-set', + } + return colors[@.dueDateStatus] or '' + + title: () -> + if @.format == 'button' + return if @.dueDate then @._formatTitle() else 'Edit due date' + + return @._formatTitle() + + _formatTitle: () -> + dueDateStatus = 'closed' + titles = { + 'no_longer_applicable': 'COMMON.DUE_DATE.NO_LONGER_APPLICABLE', + 'due_soon': 'COMMON.DUE_DATE.DUE_SOON', + 'past_due': 'COMMON.DUE_DATE.PAST_DUE', + } + prettyDate = @translate.instant("COMMON.PICKERDATE.FORMAT") + formatedDate = moment(@.dueDate).format(prettyDate) + + if not titles[@.dueDateStatus] + return formatedDate + return "#{formatedDate} (#{@translate.instant(titles[@.dueDateStatus])})" + + setDueDate: () -> + return if @.disabled() + @tgLightboxFactory.create( + "tg-lb-set-due-date", + {"class": "lightbox lightbox-set-due-date"}, + {"object": @.item} + ) + +angular.module('taigaComponents').controller('DueDate', DueDateController) diff --git a/app/modules/components/due-date/due-date-icon.jade b/app/modules/components/due-date/due-date-icon.jade new file mode 100644 index 00000000..16d2b20c --- /dev/null +++ b/app/modules/components/due-date/due-date-icon.jade @@ -0,0 +1,7 @@ +span.due-date-icon + tg-svg( + ng-if="vm.visible()" + svg-icon="icon-clock" + ng-class="vm.color()" + ng-attr-title="{{ vm.title() }}" + ) \ No newline at end of file diff --git a/app/modules/components/due-date/due-date.directive.coffee b/app/modules/components/due-date/due-date.directive.coffee new file mode 100644 index 00000000..29a9a1eb --- /dev/null +++ b/app/modules/components/due-date/due-date.directive.coffee @@ -0,0 +1,43 @@ +### +# Copyright (C) 2014-2018 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: due-date.directive.coffee +### + +module = angular.module("taigaComponents") + +dueDateDirective = () -> + templateUrl = (el, attrs) -> + if attrs.format + return "components/due-date/due-date-" + attrs.format + ".html" + return "components/due-date/due-date-icon.html" + + return { + link: (scope) -> + controller: "DueDate", + controllerAs: "vm", + bindToController: true, + templateUrl: templateUrl, + scope: { + dueDate: '=', + dueDateStatus: '=', + isClosed: '=', + item: '=', + format: '@' + } + } + +module.directive('tgDueDate', dueDateDirective) \ No newline at end of file diff --git a/app/modules/components/due-date/due-date.scss b/app/modules/components/due-date/due-date.scss new file mode 100644 index 00000000..b5aadc1b --- /dev/null +++ b/app/modules/components/due-date/due-date.scss @@ -0,0 +1,69 @@ +tg-due-date .due-date-button { + background: $gray-light; + display: inline-block; + margin-right: .5rem; + padding: 1rem; + transition: background .2s linear; + transition-delay: .1s; + &.closed, + &.closed[disabled] { + background: $gray-lighter; + } + &.due-set { + background: $yellow-green; + } + &.due-soon { + background: $my-sin; + } + &.past-due { + background: $red-light; + } + &:hover { + background: $gray; + } + &.editable { + cursor: pointer; + } +} + +tg-due-date .due-date-icon { + display: inline-block; + line-height: .1rem; + margin: 0 .25rem; + position: relative; + top: .1rem; + svg { + fill: $gray-light; + height: 1.1rem; + transition: fill .2s ease-in; + width: 1.1rem; + } + .closed svg { + fill: $gray-lighter; + } + .due-set svg { + fill: $yellow-green; + } + .due-soon svg { + fill: $my-sin; + } + .past-due svg { + fill: $red-light; + } +} + +.backlog-table-body .user-story-name .due-date-icon { + top: .25rem; +} + +.issues-table .subject .due-date-icon { + top: 0; +} + +.card-statistics .due-date-icon { + margin: 0; + svg { + height: 1rem; + width: 1rem; + } +} diff --git a/app/modules/history/history/history-diff.jade b/app/modules/history/history/history-diff.jade index 2bfaffa9..158c0ce0 100644 --- a/app/modules/history/history/history-diff.jade +++ b/app/modules/history/history/history-diff.jade @@ -33,6 +33,16 @@ ) include history-templates/history-assigned +.diff-wrapper( + ng-if="vm.type == 'assigned_users'" +) + include history-templates/history-assigned-users + +.diff-wrapper( + ng-if="vm.type == 'due_date'" +) + include history-templates/history-due-date + .diff-wrapper( ng-if="vm.type == 'tags'" ) diff --git a/app/modules/history/history/history-templates/history-assigned-users.jade b/app/modules/history/history/history-templates/history-assigned-users.jade new file mode 100644 index 00000000..ddf26e66 --- /dev/null +++ b/app/modules/history/history/history-templates/history-assigned-users.jade @@ -0,0 +1,13 @@ +.diff-status-wrapper + span.key( + translate="ACTIVITY.FIELDS.ASSIGNED_USERS" + ) + span.diff(ng-if="vm.diff[0]") {{vm.diff[0]}} + span.diff(ng-if="!vm.diff[0]" translate="ACTIVITY.VALUES.UNASSIGNED") + + tg-svg( + svg-icon="icon-arrow-right" + ) + + span.diff(ng-if="vm.diff[1]") {{vm.diff[1]}} + span.diff(ng-if="!vm.diff[1]" translate="ACTIVITY.VALUES.UNASSIGNED") diff --git a/app/modules/history/history/history-templates/history-due-date.jade b/app/modules/history/history/history-templates/history-due-date.jade new file mode 100644 index 00000000..a6847525 --- /dev/null +++ b/app/modules/history/history/history-templates/history-due-date.jade @@ -0,0 +1,13 @@ +.diff-status-wrapper + span.key( + translate="ACTIVITY.FIELDS.DUE_DATE" + ) + span.diff(ng-if="vm.diff[0]") {{vm.diff[0] | momentFormat:'DD MMM YYYY'}} + span.diff(ng-if="!vm.diff[0]" translate="ACTIVITY.VALUES.NOT_SET") + + tg-svg( + svg-icon="icon-arrow-right" + ) + + span.diff(ng-if="vm.diff[1]") {{vm.diff[1] | momentFormat:'DD MMM YYYY'}} + span.diff(ng-if="!vm.diff[1]" translate="ACTIVITY.VALUES.NOT_SET") \ No newline at end of file diff --git a/app/modules/resources/projects-resource.service.coffee b/app/modules/resources/projects-resource.service.coffee index 978593f4..03a57ad4 100644 --- a/app/modules/resources/projects-resource.service.coffee +++ b/app/modules/resources/projects-resource.service.coffee @@ -122,6 +122,7 @@ Resource = (urlsService, http, paginateResponseService) -> service.watchProject = (projectId, notifyLevel) -> data = { notify_level: notifyLevel + live_notify_level: notifyLevel } url = urlsService.resolve("project-watch", projectId) return http.post(url, data) diff --git a/app/modules/user-timeline/user-timeline-item/user-timeline-item-title.service.coffee b/app/modules/user-timeline/user-timeline-item/user-timeline-item-title.service.coffee index 92622694..f0e447bb 100644 --- a/app/modules/user-timeline/user-timeline-item/user-timeline-item-title.service.coffee +++ b/app/modules/user-timeline/user-timeline-item/user-timeline-item-title.service.coffee @@ -31,12 +31,15 @@ class UserTimelineItemTitle 'description_diff': 'COMMON.FIELDS.DESCRIPTION', 'points': 'COMMON.FIELDS.POINTS', 'assigned_to': 'COMMON.FIELDS.ASSIGNED_TO', + 'assigned_users': 'COMMON.FIELDS.ASSIGNED_USERS', 'severity': 'ISSUES.FIELDS.SEVERITY', 'priority': 'ISSUES.FIELDS.PRIORITY', 'type': 'ISSUES.FIELDS.TYPE', 'is_iocaine': 'TASK.FIELDS.IS_IOCAINE', 'is_blocked': 'COMMON.FIELDS.IS_BLOCKED', - 'color': 'COMMON.FIELDS.COLOR' + 'color': 'COMMON.FIELDS.COLOR', + 'due_date': 'COMMON.FIELDS.DUE_DATE', + 'due_date_reason': 'COMMON.FIELDS.DUE_DATE_REASON', } _params: { @@ -69,6 +72,18 @@ class UserTimelineItemTitle if value == null && timeline.getIn(["data", "value_diff", "key"]) == 'assigned_to' value = @translate.instant('ACTIVITY.VALUES.UNASSIGNED') + # assigned_users to unasigned + if value == null && timeline.getIn(["data", "value_diff", "key"]) == 'assigned_users' + value = @translate.instant('ACTIVITY.VALUES.UNASSIGNED') + + # due date + else if timeline.getIn(["data", "value_diff", "key"]) == 'due_date' + if value + prettyDate = @translate.instant("COMMON.PICKERDATE.FORMAT") + value = moment(value, "YYYY-MM-DD").format(prettyDate) + else + value = @translate.instant('ACTIVITY.VALUES.NOT_SET') + new_value = value else new_value = timeline.getIn(["data", "value_diff", "value"]).first().get(1) @@ -170,7 +185,8 @@ class UserTimelineItemTitle getTitle: (timeline, event, type) -> params = @._getParams(timeline, event, type) - + # console.log(timeline) + # console.log(event) paramsKeys = {} Object.keys(params).forEach (key) -> paramsKeys[key] = '{{' +key + '}}' diff --git a/app/modules/user-timeline/user-timeline/user-timeline.service.coffee b/app/modules/user-timeline/user-timeline/user-timeline.service.coffee index 6737f3c5..a1460987 100644 --- a/app/modules/user-timeline/user-timeline/user-timeline.service.coffee +++ b/app/modules/user-timeline/user-timeline/user-timeline.service.coffee @@ -33,6 +33,7 @@ class UserTimelineService extends taiga.Service 'status', 'subject', 'description_diff', + 'assigned_users', 'assigned_to', 'points', 'severity', @@ -48,7 +49,9 @@ class UserTimelineService extends taiga.Service 'blocked', 'moveInBacklog', 'milestone', - 'color' + 'color', + 'due_date', + 'due_date_reason' ] _invalid: [ diff --git a/app/partials/common/components/assigned-users.jade b/app/partials/common/components/assigned-users.jade new file mode 100644 index 00000000..8f2a772f --- /dev/null +++ b/app/partials/common/components/assigned-users.jade @@ -0,0 +1,64 @@ +.assigned-title(ng-if="!isAssigned") {{ "COMMON.ASSIGNED_TO.NOT_ASSIGNED" | translate }} +.assigned-title(ng-if="isAssigned") {{ "COMMON.FIELDS.ASSIGNED_TO" | translate }} + +.tg-assigned-users + .not-assigned-users(ng-if="!isAssigned") + .user-avatar(ng-class="{'is-iocaine': isIocaine}") + img( + tg-avatar="" + alt="{{ 'COMMON.ASSIGNED_TO.ASSIGN' | translate }}" + ) + + //- .iocaine-symbol(ng-if="isIocaine" title="{{ 'TASK.TITLE_ACTION_IOCAINE' | translate }}") + //- tg-svg(svg-icon="icon-iocaine") + .assigned-to + .assigned-users-options(ng-if="isEditable") + a( + href="" + title="{{ 'COMMON.ASSIGNED_TO.TITLE_ACTION_EDIT_ASSIGNMENT'|translate }}" + class="user-assigned" + ng-class="{editable: isEditable}" + ng-click="openAssignedUsers()" + ) + span.assigned-name {{ "COMMON.ASSIGNED_TO.ASSIGN" | translate }} + + span(ng-if="!isAssigned") + span(translate="COMMON.OR") + |   + a.assign-to-me( + href="#" + title="{{'COMMON.ASSIGNED_TO.SELF' | translate}}" + ng-click="assignToMe()" + ) + span {{ "COMMON.ASSIGNED_TO.SELF" | translate }} + + .user-list-single(ng-repeat="assignedUser in assignedUsers") + .user-list-avatar + img( + tg-avatar="assignedUser" + alt="{{assignedUser.full_name_display}}" + ) + .user-list-name.assigned-users-options + a( + href="" + title="{{ 'COMMON.ASSIGNED_TO.TITLE_ACTION_EDIT_ASSIGNMENT'|translate }}" + class!="user-assigned <% if (isEditable) { %>editable<% }; %>" + ) + span.assigned-name {{assignedUser.full_name_display}} + tg-svg.remove-user( + ng-if="isEditable", + data-assigned-user-id="{{assignedUser.id}}" + svg-icon="icon-close", + title="{{'COMMON.ASSIGNED_TO.DELETE_ASSIGNMENT' | translate}}" + ) + + +.tg-add-assigned(ng-if="isAssigned && isEditable") + tg-svg.add-assigned( + ng-if="isEditable", + data-assigned-user-id="{{assignedUser.id}}", + ng-click="openAssignedUsers()", + svg-icon="icon-add", + title="{{'COMMON.ASSIGNED_USERS.ADD_ASSIGNED' | translate}}" + ) + span {{ "COMMON.ASSIGNED_USERS.ADD_ASSIGNED" | translate }} diff --git a/app/partials/common/lightbox/lightbox-assigned-users-users.jade b/app/partials/common/lightbox/lightbox-assigned-users-users.jade new file mode 100644 index 00000000..2ec6eb4d --- /dev/null +++ b/app/partials/common/lightbox/lightbox-assigned-users-users.jade @@ -0,0 +1,48 @@ +//- <% if (selected) { %> +<% _.each(selected, function(user) { %> +.user-list-multiple.is-active(data-user-id!="<%- user.id %>") + .user-list-avatar + a( + href="" + title="{{'COMMON.ASSIGNED_TO' | translate}}" + ) + img( + style!="background: <%- user.avatar.bg %>" + src!="<%- user.avatar.url %>" + ) + a.user-list-name( + href="" + title!="<%- user.full_name_display %>" + ng-non-bindable + ) + | <%-user.full_name_display %> + tg-svg.remove-assigned-to( + svg-icon="icon-close", + svg-title-translate="COMMON.ASSIGNED_TO.REMOVE_ASSIGNED", + data-user-id!="<%- user.id %>" + ) +<% }) %> + +<% _.each(users, function(user) { %> +.user-list-single(data-user-id!="<%- user.id %>") + .user-list-avatar + a( + href="#" + title="{{'COMMON.ASSIGNED_TO.TITLE' | translate}}" + ) + img( + style!="background: <%- user.avatar.bg %>" + src!="<%- user.avatar.url %>" + ) + a.user-list-name( + href="" + title!="<%- user.full_name_display %>" + ng-non-bindable + ) + | <%- user.full_name_display %> +<% }) %> + +<% if (showMore) { %> +.more-watchers + span(translate="COMMON.ASSIGNED_TO.TOO_MANY") +<% } %> diff --git a/app/partials/common/lightbox/lightbox-assigned-users.jade b/app/partials/common/lightbox/lightbox-assigned-users.jade new file mode 100644 index 00000000..85abe7b6 --- /dev/null +++ b/app/partials/common/lightbox/lightbox-assigned-users.jade @@ -0,0 +1,8 @@ +tg-lightbox-close + +div.form + h2.title(translate="COMMON.ASSIGNED_USERS.ADD") + fieldset + input(type="text", data-maxlength="500", placeholder="{{'LIGHTBOX.ASSIGNED_TO.SEARCH' | translate}}", ng-model="usersSearch") + div.assigned-to-list + //- The content of this is rendered by directive diff --git a/app/partials/common/lightbox/lightbox-due-date.jade b/app/partials/common/lightbox/lightbox-due-date.jade new file mode 100644 index 00000000..8529338f --- /dev/null +++ b/app/partials/common/lightbox/lightbox-due-date.jade @@ -0,0 +1,44 @@ +tg-lightbox-close + +form + h2.title(translate="LIGHTBOX.SET_DUE_DATE.TITLE") + + fieldset.date + input.due-date.no-focus( + type="text" + name="due_date" + picker-value="{{ new_due_date }}" + data-required="true" + tg-date-selector + placeholder="{{'LIGHTBOX.SET_DUE_DATE.PLACEHOLDER_DUE_DATE' | translate}}" + ) + + ul.due-date-suggestions + li.suggestion.clickable(data-quantity=1, data-unit="weeks") + span {{ 'LIGHTBOX.SET_DUE_DATE.SUGGESTIONS.IN_ONE_WEEK' | translate }} + li.suggestion.clickable(data-quantity=2, data-unit="weeks") + span {{ 'LIGHTBOX.SET_DUE_DATE.SUGGESTIONS.IN_TWO_WEEKS' | translate }} + li.suggestion.clickable(data-quantity=1, data-unit="months") + span {{ 'LIGHTBOX.SET_DUE_DATE.SUGGESTIONS.IN_ONE_MONTH' | translate }} + li.suggestion.clickable(data-quantity=3, data-unit="months") + span {{ 'LIGHTBOX.SET_DUE_DATE.SUGGESTIONS.IN_THREE_MONTHS' | translate }} + + fieldset.reason + span {{ 'LIGHTBOX.SET_DUE_DATE.REASON_FOR_DUE_DATE' | translate }} + textarea.due-date-reason.no-focus( + name="due_date_reason" + ng-attr-placeholder="{{'LIGHTBOX.SET_DUE_DATE.PLACEHOLDER_REASON_FOR_DUE_DATE' | translate}}" + ng-model="object.due_date_reason" + ) + + button.button-green.submit-button( + type="submit" + title="{{'COMMON.SAVE' | translate}}" + translate="COMMON.SAVE" + ) + + a.delete-due-date( + href="" + title="{{'LIGHTBOX.SET_DUE_DATE.TITLE_ACTION_DELETE_DUE_DATE' | translate}}" + ) + tg-svg(svg-icon="icon-trash") diff --git a/app/partials/includes/components/backlog-row.jade b/app/partials/includes/components/backlog-row.jade index 10af8afe..42764666 100644 --- a/app/partials/includes/components/backlog-row.jade +++ b/app/partials/includes/components/backlog-row.jade @@ -27,6 +27,11 @@ ) span(tg-bo-ref="us.ref") span(ng-bind-html="us.subject | emojify") + tg-due-date( + due-date="us.due_date" + due-date-status="us.due_date_status" + ng-if="us.due_date" + ) tg-belong-to-epics( format="pill" ng-if="us.epics" diff --git a/app/partials/includes/modules/issues-table.jade b/app/partials/includes/modules/issues-table.jade index 3a3e35c0..d004eba4 100644 --- a/app/partials/includes/modules/issues-table.jade +++ b/app/partials/includes/modules/issues-table.jade @@ -54,6 +54,11 @@ section.issues-table.basic-table(ng-class="{empty: !issues.length}") title="{{issue.blocked_note}}" ) {{'ISSUES.TABLE.BLOCKED' | translate}} span(ng-bind-html="issue.subject | emojify") + tg-due-date( + due-date="issue.due_date" + due-date-status="issue.due_date_status" + ng-if="issue.due_date" + ) div.issue-field(tg-issue-status-inline-edition="issue") diff --git a/app/partials/includes/modules/kanban-table.jade b/app/partials/includes/modules/kanban-table.jade index f5e39d9b..3a478774 100644 --- a/app/partials/includes/modules/kanban-table.jade +++ b/app/partials/includes/modules/kanban-table.jade @@ -80,7 +80,8 @@ div.kanban-table( tg-bind-scope, on-toggle-fold="ctrl.toggleFold(id)" on-click-edit="ctrl.editUs(id)" - on-click-assigned-to="ctrl.changeUsAssignedTo(id)" + on-click-delete="ctrl.deleteUs(id)" + on-click-assigned-to="ctrl.changeUsAssignedUsers(id)" project="project" item="us" zoom="ctrl.zoom" diff --git a/app/partials/includes/modules/taskboard-table.jade b/app/partials/includes/modules/taskboard-table.jade index 1da302f6..5c567ff5 100644 --- a/app/partials/includes/modules/taskboard-table.jade +++ b/app/partials/includes/modules/taskboard-table.jade @@ -82,6 +82,7 @@ div.taskboard-table( tg-bind-scope, on-toggle-fold="ctrl.toggleFold(id)" on-click-edit="ctrl.editTask(id)" + on-click-delete="ctrl.deleteTask(id)" on-click-assigned-to="ctrl.changeTaskAssignedTo(id)" project="project" item="task" @@ -127,6 +128,7 @@ div.taskboard-table( tg-class-permission="{'readonly': '!modify_task'}" on-toggle-fold="ctrl.toggleFold(id)" on-click-edit="ctrl.editTask(id)" + on-click-delete="ctrl.deleteTask(id)" on-click-assigned-to="ctrl.changeTaskAssignedTo(id)" project="project" item="task" diff --git a/app/partials/includes/modules/user-settings-menu.jade b/app/partials/includes/modules/user-settings-menu.jade index 05a7cb7c..070e6ac2 100644 --- a/app/partials/includes/modules/user-settings-menu.jade +++ b/app/partials/includes/modules/user-settings-menu.jade @@ -10,6 +10,9 @@ section.admin-menu li#usersettingsmenu-mail-notifications a(href="", tg-nav="user-settings-mail-notifications", title="{{ 'USER_SETTINGS.MENU.EMAIL_NOTIFICATIONS' | translate }}") span.title(translate="USER_SETTINGS.MENU.EMAIL_NOTIFICATIONS") + li#usersettingsmenu-live-notifications + a(href="", tg-nav="user-settings-live-notifications", title="{{ 'USER_SETTINGS.MENU.DESKTOP_NOTIFICATIONS' | translate }}") + span.title(translate="USER_SETTINGS.MENU.DESKTOP_NOTIFICATIONS") li#usersettings-contrib(ng-repeat="plugin in userSettingsPlugins") a( href="" diff --git a/app/partials/includes/modules/user-settings/live-notifications-table.jade b/app/partials/includes/modules/user-settings/live-notifications-table.jade new file mode 100644 index 00000000..077a2f79 --- /dev/null +++ b/app/partials/includes/modules/user-settings/live-notifications-table.jade @@ -0,0 +1,12 @@ +section.policy-table + div.policy-table-header + div.policy-table-row + div.policy-table-project + span(translate="USER_SETTINGS.NOTIFICATIONS.COLUMN_PROJECT") + div.policy-table-all + span(translate="USER_SETTINGS.NOTIFICATIONS.COLUMN_RECEIVE_ALL") + div.policy-table-involved + span(translate="USER_SETTINGS.NOTIFICATIONS.COLUMN_ONLY_INVOLVED") + div.policy-table-none + span(translate="USER_SETTINGS.NOTIFICATIONS.COLUMN_NO_NOTIFICATIONS") + div.policy-table-body(tg-user-live-notifications-list, ng-model="notifyPolicies") diff --git a/app/partials/issue/issues-detail.jade b/app/partials/issue/issues-detail.jade index 0b0d15a3..cc292a2d 100644 --- a/app/partials/issue/issues-detail.jade +++ b/app/partials/issue/issues-detail.jade @@ -113,6 +113,14 @@ div.wrapper( ) section.ticket-detail-settings + tg-due-date( + tg-check-permission="modify_issue" + due-date="issue.due_date" + due-date-status="issue.due_date_status" + is-closed="issue.is_closed" + item="issue" + format="button" + ) tg-promote-issue-to-us-button( tg-check-permission="add_us", ng-model="issue" diff --git a/app/partials/kanban/kanban.jade b/app/partials/kanban/kanban.jade index 3a63c543..28eb7134 100644 --- a/app/partials/kanban/kanban.jade +++ b/app/partials/kanban/kanban.jade @@ -46,4 +46,4 @@ div.wrapper( div.lightbox.lightbox-generic-bulk(tg-lb-create-bulk-userstories) include ../includes/modules/lightbox-us-bulk - div.lightbox.lightbox-select-user(tg-lb-assignedto) + div.lightbox.lightbox-select-user(tg-lb-assigned-users) diff --git a/app/partials/task/related-task-row.jade b/app/partials/task/related-task-row.jade index cde848b1..32ee05b9 100644 --- a/app/partials/task/related-task-row.jade +++ b/app/partials/task/related-task-row.jade @@ -3,7 +3,11 @@ tg-nav="project-tasks-detail:project=project.slug,ref=task.ref") span #<%- task.ref %> span(ng-non-bindable) <%= emojify(task.subject) %> - + tg-due-date( + due-date="task.due_date" + due-date-status="task.due_date_status" + ng-if="task.due_date" + ) .task-settings <% if(perms.modify_task) { %> a.edit-task( diff --git a/app/partials/task/task-detail.jade b/app/partials/task/task-detail.jade index 3cc5939d..3d1e6357 100644 --- a/app/partials/task/task-detail.jade +++ b/app/partials/task/task-detail.jade @@ -102,6 +102,14 @@ div.wrapper( ) section.ticket-detail-settings + tg-due-date( + tg-check-permission="modify_task" + due-date="task.due_date" + due-date-status="task.due_date_status" + is-closed="task.is_closed" + item="task" + format="button" + ) tg-task-is-iocaine-button(ng-model="task") tg-block-button(tg-check-permission="modify_task", ng-model="task") tg-delete-button( diff --git a/app/partials/us/us-detail.jade b/app/partials/us/us-detail.jade index be89305b..6f5378a9 100644 --- a/app/partials/us/us-detail.jade +++ b/app/partials/us/us-detail.jade @@ -95,8 +95,8 @@ div.wrapper( tg-us-estimation.ticket-estimation(ng-model="us") - section.ticket-assigned-to( - tg-assigned-to + section.ticket-assigned-users( + tg-assigned-users ng-model="us" required-perm="modify_us" ) @@ -127,6 +127,14 @@ div.wrapper( ) {{'US.TRIBE.PUBLISH_INFO' | translate}} section.ticket-detail-settings + tg-due-date( + tg-check-permission="modify_us" + due-date="us.due_date" + due-date-status="us.due_date_status" + is-closed="us.is_closed" + item="us" + format="button" + ) tg-us-team-requirement-button(ng-model="us") tg-us-client-requirement-button(ng-model="us") tg-block-button( @@ -146,4 +154,5 @@ div.wrapper( ng-model="us" ) 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) diff --git a/app/partials/user/live-notifications.jade b/app/partials/user/live-notifications.jade new file mode 100644 index 00000000..5d357c68 --- /dev/null +++ b/app/partials/user/live-notifications.jade @@ -0,0 +1,19 @@ +doctype html + +div.wrapper( + tg-user-live-notifications + ng-controller="UserLiveNotificationsController as ctrl", + ng-init="section='live-notifications'" +) + + sidebar.menu-secondary.sidebar.settings-nav(tg-user-settings-navigation="live-notifications") + include ../includes/modules/user-settings-menu + + section.main.admin-common + header + h1 + span.green {{sectionName | translate}} + + p.total(translate="NOTIFICATION.DESKTOP") + + include ../includes/modules/user-settings/live-notifications-table diff --git a/app/styles/components/user-list.scss b/app/styles/components/user-list.scss index b52796a2..732b48cb 100644 --- a/app/styles/components/user-list.scss +++ b/app/styles/components/user-list.scss @@ -12,6 +12,19 @@ border: 0; } } + .user-list-multiple { + align-content: center; + align-items: center; + background: transparent; + border-bottom: 1px solid $whitish; + display: flex; + padding: .25rem 0; + vertical-align: middle; + + &:last-child { + border: 0; + } + } .user-list-avatar { flex-basis: 3rem; margin-right: .25rem; @@ -61,6 +74,37 @@ } } } + + .user-list-multiple { + &:hover, + &.selected { + background: rgba(lighten($primary-light, 30%), .3); + cursor: pointer; + } + &:hover { + transition: background .3s linear; + transition-delay: .2s; + } + &.is-active { + background: rgba(lighten($primary-light, 30%), .3); + cursor: pointer; + position: relative; + transition: background .3s linear; + transition-delay: .1s; + } + .remove-assigned-to { + display: block; + fill: $grayer; + opacity: 1; + position: absolute; + right: 1rem; + top: 1.5rem; + transition: all .2s ease-in; + &:hover { + fill: $red; + } + } + } } .ticket-watchers { @@ -92,3 +136,35 @@ } } } + +.ticket-assigned-users { + @include user-list; + margin-top: 1rem; + .user-list-single { + flex-grow: 1; + &:hover { + .remove-user { + opacity: 1; + transition: opacity .2s ease-in; + } + } + } + .user-list-name { + @include font-type(text); + flex: 1; + position: relative; + } + .remove-user { + cursor: pointer; + fill: currentColor; + opacity: 0; + position: absolute; + right: .5rem; + top: 0; + transition: all .2s ease-in; + &:hover { + fill: $red; + transition: color .3s ease-in; + } + } +} diff --git a/app/styles/modules/common/assigned-users.scss b/app/styles/modules/common/assigned-users.scss new file mode 100644 index 00000000..900cb4c2 --- /dev/null +++ b/app/styles/modules/common/assigned-users.scss @@ -0,0 +1,139 @@ +.ticket-assigned-users { + align-items: center; + border-bottom: 1px solid $gray-light; + border-top: 1px solid $gray-light; + margin-bottom: 1rem; + padding: .5rem 0; + position: relative; + + .loading-spinner { + @include loading-spinner; + margin: 1rem auto; + max-height: 2rem; + max-width: 2rem; + } + + .assigned-title { + @include font-size(small); + @include font-type(light); + color: $gray; + display: block; + margin: .2rem 0 .25rem; + } + + .tg-assigned-users { + align-items: center; + position: relative; + } + + .tg-add-assigned { + margin-top: .25rem; + + .add-assigned { + fill: $gray; + opacity: 1; + right: .5rem; + top: 2rem; + &:hover { + cursor: pointer; + fill: $red; + transition: fill .2s; + } + } + + span { + @include font-size(small); + @include font-type(light); + color: $gray; + margin: .2rem .5rem; + } + } + + .assigned-users-options { + align-content: center; + display: flex; + a { + margin-right: .2rem; + } + } + + .user-assigned, + .assign-to-me { + color: $primary; + &.editable { + color: $primary; + &:hover { + cursor: pointer; + } + } + .icon { + vertical-align: middle; + } + } + + .not-assigned-users { + align-items: center; + display: flex; + + .assigned-title { + @include font-size(small); + @include font-type(light); + color: $gray; + display: block; + margin: .2rem 0 .25rem; + } + .assigned-to { + flex-grow: 1; + margin-left: .5rem; + } + + .assigned-to-options { + align-content: center; + display: flex; + a { + margin-right: .2rem; + } + } + .user-assigned, + .assign-to-me { + color: $primary; + cursor: default; + &:hover { + cursor: pointer; + } + .icon { + fill: currentColor; + height: .75rem; + width: .75rem; + } + } + } + + .user-avatar { + flex-basis: 3rem; + flex-shrink: 0; + position: relative; + img { + width: 100%; + } + &.is-iocaine { + img { + filter: hue-rotate(150deg) saturate(200%); + } + } + .iocaine-symbol { + left: -.5rem; + position: absolute; + top: -.75rem; + z-index: 9; + svg { + background: $grayer; + border-radius: .25rem; + fill: $white; + min-height: 1.75rem; + min-width: 1.75rem; + padding: .25rem; + } + } + } +} diff --git a/app/styles/modules/common/lightbox.scss b/app/styles/modules/common/lightbox.scss index 255254d3..a0cb4161 100644 --- a/app/styles/modules/common/lightbox.scss +++ b/app/styles/modules/common/lightbox.scss @@ -540,3 +540,63 @@ width: 500px; } } + +.lightbox-set-due-date { + z-index: 9999; + form { + flex-basis: 600px; + flex-flow: 0; + max-width: 600px; + } + .date { + margin: 2rem 0 1rem; + } + .reason textarea { + margin-top: .5rem; + } + .due-date-suggestions { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin: 1rem 0 .5rem; + } + .suggestion { + background: rgba($gray-lighter, .2); + color: $gray-lighter; + justify-content: flex-start; + margin: 0 .5rem .5rem; + min-height: 2rem; + padding: .5rem .75rem; + position: relative; + &:first-child { + margin-left: 0; + } + &:nth-child(4n + 4) { + margin-right: 0; + } + &.clickable { + &:hover, + &.active { + background: rgba($primary-light, .9); + color: $white; + } + } + } + .delete-due-date { + @include font-size(small); + color: $gray; + float: right; + margin: 1rem .25rem 0 0; + transition: color .3s linear; + .icon { + fill: currentColor; + } + &:hover { + color: $red; + transition: color .3s linear; + .icon { + fill: currentColor; + } + } + } +} diff --git a/app/svg/sprite.svg b/app/svg/sprite.svg index bdca9a97..8be33777 100644 --- a/app/svg/sprite.svg +++ b/app/svg/sprite.svg @@ -244,6 +244,10 @@ class="path1" d="M202.24-0.179v129.093l-202.24-0.11v895.375h729.6v-234.419h-64v170.419h-601.6v-644.385h601.6v287.086h64v-474.076h-64v0.11h-138.24v-129.093h-325.12zM266.24 63.821h197.12v96.44h0.32v32.653h201.92v58.88h-601.6v-58.88h202.24v-129.093zM129.165 393.242v64h468.838v-64h-468.836zM522.76 515.302l-181.020 181.018 181.020 181.020 45.256-45.253-103.759-103.767h559.744v-64h-559.749l103.764-103.764-45.253-45.256zM129.165 541.722v64h228.086v-64h-228.083zM129.165 690.202v64h150.246v-64h-150.246zM129.165 833.562v64h258.854v-64h-258.854z"> + + clock + + document