diff --git a/CHANGELOG.md b/CHANGELOG.md index dbe414b0..d4f396d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,12 @@ # Changelog # +## 1.10.0 ??? (unreleased) -## 1.10.0 ??? (Unreleased) - -### Features -- ... - -### Misc -- Lots of small and not so small bugfixes. - - -## 1.9.1 Taiga Tribe (2016-01-05) - -### Features +- Statics folder hash. +- Attachments image gallery. +- Upload attachments on US/issue/task lightbox. +- Drag files from desktop. +- Drag files in wysiwyg. - [118n] Now taiga plugins can be translatable. - New Taiga plugins system. - Now superadmins can send notifications (live announcement) to the user (through taiga-events). diff --git a/app/coffee/modules/backlog/main.coffee b/app/coffee/modules/backlog/main.coffee index 9c79c955..e02d83f3 100644 --- a/app/coffee/modules/backlog/main.coffee +++ b/app/coffee/modules/backlog/main.coffee @@ -54,11 +54,12 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F "$tgEvents", "$tgAnalytics", "$translate", - "$tgLoading" + "$tgLoading", + "tgResources" ] constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, - @location, @appMetaService, @navUrls, @events, @analytics, @translate, @loading) -> + @location, @appMetaService, @navUrls, @events, @analytics, @translate, @loading, @rs2) -> bindMethods(@) @scope.sectionName = @translate.instant("BACKLOG.SECTION_NAME") @@ -566,10 +567,10 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F .timeout(200) .start() - @rs.userstories.getByRef(projectId, ref).then (us) => - @rootscope.$broadcast("usform:edit", us) - - currentLoading.finish() + return @rs.userstories.getByRef(projectId, ref).then (us) => + @rs2.attachments.list("us", us.id, projectId).then (attachments) => + @rootscope.$broadcast("usform:edit", us, attachments.toJS()) + currentLoading.finish() deleteUserStory: (us) -> title = @translate.instant("US.TITLE_DELETE_ACTION") diff --git a/app/coffee/modules/common.coffee b/app/coffee/modules/common.coffee index 9af62ae1..e71ffcc1 100644 --- a/app/coffee/modules/common.coffee +++ b/app/coffee/modules/common.coffee @@ -96,22 +96,28 @@ module.factory("$selectedText", ["$window", "$document", SelectedText]) ## Permission directive, hide elements when necessary ############################################################################# -CheckPermissionDirective = -> +CheckPermissionDirective = (projectService) -> render = ($el, project, permission) -> - $el.removeClass('hidden') if project.my_permissions.indexOf(permission) > -1 + $el.removeClass('hidden') if project.get('my_permissions').indexOf(permission) > -1 link = ($scope, $el, $attrs) -> $el.addClass('hidden') permission = $attrs.tgCheckPermission - $scope.$watch "project", (project) -> - render($el, project, permission) if project? + $scope.$watch ( () -> + return projectService.project + ), () -> + render($el, projectService.project, permission) if projectService.project $scope.$on "$destroy", -> $el.off() return {link:link} +CheckPermissionDirective.$inject = [ + "tgProjectService" +] + module.directive("tgCheckPermission", CheckPermissionDirective) ############################################################################# diff --git a/app/coffee/modules/common/attachments.coffee b/app/coffee/modules/common/attachments.coffee deleted file mode 100644 index b2eaf6e6..00000000 --- a/app/coffee/modules/common/attachments.coffee +++ /dev/null @@ -1,343 +0,0 @@ -### -# Copyright (C) 2014-2016 Andrey Antukh -# Copyright (C) 2014-2016 Jesús Espino Garcia -# Copyright (C) 2014-2016 David Barragán Merino -# Copyright (C) 2014-2016 Alejandro Alonso -# Copyright (C) 2014-2016 Juan Francisco Alcántara -# Copyright (C) 2014-2016 Xavi Julian -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -# -# File: modules/common/attachments.coffee -### - -taiga = @.taiga -sizeFormat = @.taiga.sizeFormat -bindOnce = @.taiga.bindOnce -bindMethods = @.taiga.bindMethods - -module = angular.module("taigaCommon") - - -class AttachmentsController extends taiga.Controller - @.$inject = ["$scope", "$rootScope", "$tgRepo", "$tgResources", "$tgConfirm", "$q", "$translate"] - - constructor: (@scope, @rootscope, @repo, @rs, @confirm, @q, @translate) -> - bindMethods(@) - @.type = null - @.objectId = null - @.projectId = null - - @.uploadingAttachments = [] - @.attachments = [] - @.attachmentsCount = 0 - @.deprecatedAttachmentsCount = 0 - @.showDeprecated = false - - initialize: (type, objectId) -> - @.type = type - @.objectId = objectId - @.projectId = @scope.projectId - - loadAttachments: -> - return @.attachments if not @.objectId - - urlname = "attachments/#{@.type}" - - return @rs.attachments.list(urlname, @.objectId, @.projectId).then (attachments) => - @.attachments = _.sortBy(attachments, "order") - @.updateCounters() - return attachments - - updateCounters: -> - @.attachmentsCount = @.attachments.length - @.deprecatedAttachmentsCount = _.filter(@.attachments, {is_deprecated: true}).length - - _createAttachment: (attachment) -> - urlName = "attachments/#{@.type}" - - promise = @rs.attachments.create(urlName, @.projectId, @.objectId, attachment) - promise = promise.then (data) => - data.isCreatedRightNow = true - - index = @.uploadingAttachments.indexOf(attachment) - @.uploadingAttachments.splice(index, 1) - @.attachments.push(data) - @rootscope.$broadcast("attachment:create") - - promise = promise.then null, (data) => - @scope.$emit("attachments:size-error") if data.status == 413 - - index = @.uploadingAttachments.indexOf(attachment) - @.uploadingAttachments.splice(index, 1) - - message = @translate.instant("ATTACHMENT.ERROR_UPLOAD_ATTACHMENT", { - fileName: attachment.name, errorMessage: data.data._error_message}) - @confirm.notify("error", message) - return @q.reject(data) - - return promise - - # Create attachments in bulk - createAttachments: (attachments) -> - promises = _.map(attachments, (x) => @._createAttachment(x)) - return @q.all(promises).then => - @.updateCounters() - - # Add uploading attachment tracking. - addUploadingAttachments: (attachments) -> - @.uploadingAttachments = _.union(@.uploadingAttachments, attachments) - - # Change order of attachment in a ordered list. - # This function is mainly executed after sortable ends. - reorderAttachment: (attachment, newIndex) -> - oldIndex = @.attachments.indexOf(attachment) - return if oldIndex == newIndex - - @.attachments.splice(oldIndex, 1) - @.attachments.splice(newIndex, 0, attachment) - - _.each(@.attachments, (x,i) -> x.order = i+1) - - # Persist one concrete attachment. - # This function is mainly used when user clicks - # to save button for save one unique attachment. - updateAttachment: (attachment) -> - onSuccess = => - @.updateCounters() - @rootscope.$broadcast("attachment:edit") - - onError = (response) => - $scope.$emit("attachments:size-error") if response.status == 413 - @confirm.notify("error") - return @q.reject() - - return @repo.save(attachment).then(onSuccess, onError) - - # Persist all pending modifications on attachments. - # This function is used mainly for persist the order - # after sorting. - saveAttachments: -> - return @repo.saveAll(@.attachments).then null, => - for item in @.attachments - item.revert() - @.attachments = _.sortBy(@.attachments, "order") - - # Remove one concrete attachment. - removeAttachment: (attachment) -> - title = @translate.instant("ATTACHMENT.TITLE_LIGHTBOX_DELETE_ATTACHMENT") - message = @translate.instant("ATTACHMENT.MSG_LIGHTBOX_DELETE_ATTACHMENT", {fileName: attachment.name}) - - return @confirm.askOnDelete(title, message).then (askResponse) => - onSuccess = => - askResponse.finish() - index = @.attachments.indexOf(attachment) - @.attachments.splice(index, 1) - @.updateCounters() - @rootscope.$broadcast("attachment:delete") - - onError = => - askResponse.finish(false) - message = @translate.instant("ATTACHMENT.ERROR_DELETE_ATTACHMENT", {errorMessage: message}) - @confirm.notify("error", null, message) - return @q.reject() - - return @repo.remove(attachment).then(onSuccess, onError) - - # Function used in template for filter visible attachments - filterAttachments: (item) -> - if @.showDeprecated - return true - return not item.is_deprecated - - -AttachmentsDirective = ($config, $confirm, $templates, $translate) -> - template = $templates.get("attachment/attachments.html", true) - - link = ($scope, $el, $attrs, $ctrls) -> - $ctrl = $ctrls[0] - $model = $ctrls[1] - - bindOnce $scope, $attrs.ngModel, (value) -> - $ctrl.initialize($attrs.type, value.id) - $ctrl.loadAttachments() - - tdom = $el.find("div.attachment-body.sortable") - tdom.sortable({ - items: "div.single-attachment" - handle: "a.settings.icon.icon-drag-v" - containment: ".attachments" - dropOnEmpty: true - scroll: false - tolerance: "pointer" - placeholder: "sortable-placeholder single-attachment" - }) - - tdom.on "sortstop", (event, ui) -> - attachment = ui.item.scope().attach - newIndex = ui.item.index() - - $ctrl.reorderAttachment(attachment, newIndex) - $ctrl.saveAttachments().then -> - $scope.$emit("attachment:edit") - - showSizeInfo = -> - $el.find(".size-info").removeClass("hidden") - - $scope.$on "attachments:size-error", -> - showSizeInfo() - - $el.on "change", ".attachments-header input", (event) -> - files = _.toArray(event.target.files) - - return if files.length < 1 - - $scope.$apply -> - $ctrl.addUploadingAttachments(files) - $ctrl.createAttachments(files) - - $el.on "click", ".more-attachments", (event) -> - event.preventDefault() - target = angular.element(event.currentTarget) - - $scope.$apply -> - $ctrl.showDeprecated = not $ctrl.showDeprecated - - target.find("span.text").addClass("hidden") - if $ctrl.showDeprecated - target.find("span[data-type=hide]").removeClass("hidden") - target.find("more-attachments-num").addClass("hidden") - else - target.find("span[data-type=show]").removeClass("hidden") - target.find("more-attachments-num").removeClass("hidden") - - $scope.$on "$destroy", -> - $el.off() - - templateFn = ($el, $attrs) -> - maxFileSize = $config.get("maxUploadFileSize", null) - maxFileSize = sizeFormat(maxFileSize) if maxFileSize - maxFileSizeMsg = if maxFileSize then $translate.instant("ATTACHMENT.MAX_UPLOAD_SIZE", {maxFileSize: maxFileSize}) else "" - ctx = { - type: $attrs.type - maxFileSize: maxFileSize - maxFileSizeMsg: maxFileSizeMsg - } - return template(ctx) - - return { - require: ["tgAttachments", "ngModel"] - controller: AttachmentsController - controllerAs: "ctrl" - restrict: "AE" - scope: true - link: link - template: templateFn - } - -module.directive("tgAttachments", ["$tgConfig", "$tgConfirm", "$tgTemplate", "$translate", AttachmentsDirective]) - - -AttachmentDirective = ($template, $compile, $translate, $rootScope) -> - template = $template.get("attachment/attachment.html", true) - templateEdit = $template.get("attachment/attachment-edit.html", true) - - link = ($scope, $el, $attrs, $ctrl) -> - render = (attachment, edit=false) -> - permissions = $scope.project.my_permissions - modifyPermission = permissions.indexOf("modify_#{$ctrl.type}") > -1 - - ctx = { - id: attachment.id - name: attachment.name - title : $translate.instant("ATTACHMENT.TITLE", { - fileName: attachment.name, - date: moment(attachment.created_date).format($translate.instant("ATTACHMENT.DATE"))}) - url: attachment.url - size: sizeFormat(attachment.size) - description: attachment.description - isDeprecated: attachment.is_deprecated - modifyPermission: modifyPermission - } - - if edit - html = $compile(templateEdit(ctx))($scope) - else - html = $compile(template(ctx))($scope) - - $el.html(html) - - if attachment.is_deprecated - $el.addClass("deprecated") - $el.find("input:checkbox").prop('checked', true) - else - $el.removeClass("deprecated") - - saveAttachment = -> - attachment.description = $el.find("input[name='description']").val() - attachment.is_deprecated = $el.find("input[name='is-deprecated']").prop("checked") - attachment.isCreatedRightNow = false - - $scope.$apply -> - $ctrl.updateAttachment(attachment).then -> - render(attachment, false) - - ## Actions (on edit mode) - $el.on "click", "a.editable-settings.icon-floppy", (event) -> - event.preventDefault() - saveAttachment() - - $el.on "keyup", "input[name=description]", (event) -> - if event.keyCode == 13 - saveAttachment() - else if event.keyCode == 27 - $scope.$apply -> render(attachment, false) - - $el.on "click", "a.editable-settings.icon-delete", (event) -> - event.preventDefault() - render(attachment, false) - - ## Actions (on view mode) - $el.on "click", "a.settings.icon-edit", (event) -> - event.preventDefault() - render(attachment, true) - $el.find("input[name='description']").focus().select() - - $el.on "click", "a.settings.icon-delete", (event) -> - event.preventDefault() - $scope.$apply -> - $ctrl.removeAttachment(attachment) - - $el.on "click", "div.attachment-name a", (event) -> - if null != attachment.name.match(/\.(jpe?g|png|gif|gifv|webm)/i) - event.preventDefault() - $scope.$apply -> - $rootScope.$broadcast("attachment:preview", attachment) - - $scope.$on "$destroy", -> - $el.off() - - # Bootstrap - attachment = $scope.$eval($attrs.tgAttachment) - render(attachment, attachment.isCreatedRightNow) - if attachment.isCreatedRightNow - $el.find("input[name='description']").focus().select() - - return { - link: link - require: "^tgAttachments" - restrict: "AE" - } - -module.directive("tgAttachment", ["$tgTemplate", "$compile", "$translate", "$rootScope", AttachmentDirective]) diff --git a/app/coffee/modules/common/components.coffee b/app/coffee/modules/common/components.coffee index 1bb993e4..adee97c1 100644 --- a/app/coffee/modules/common/components.coffee +++ b/app/coffee/modules/common/components.coffee @@ -607,8 +607,79 @@ EditableDescriptionDirective = ($rootscope, $repo, $confirm, $compile, $loading, template: template } -module.directive("tgEditableDescription", ["$rootScope", "$tgRepo", "$tgConfirm", "$compile", "$tgLoading", - "$selectedText", "$tgQqueue", "$tgTemplate", EditableDescriptionDirective]) +module.directive("tgEditableDescription", [ + "$rootScope", + "$tgRepo", + "$tgConfirm", + "$compile", + "$tgLoading", + "$selectedText", + "$tgQqueue", + "$tgTemplate", EditableDescriptionDirective]) + + + +EditableWysiwyg = (attachmentsService) -> + link = ($scope, $el, $attrs, $model) -> + + isInEditMode = -> + return $el.find('textarea').is(':visible') + + $el.on 'dragover', (e) -> + textarea = $el.find('textarea').focus() + + return false + + $el.on 'drop', (e) -> + e.stopPropagation() + e.preventDefault() + + if isInEditMode() + dataTransfer = e.dataTransfer || (e.originalEvent && e.originalEvent.dataTransfer) + + textarea = $el.find('textarea') + + textarea.addClass('in-progress') + + type = $model.$modelValue['_name'] + + if type == "userstories" + type = "us" + else if type == "tasks" + type = "task" + else if type == "issues" + type = "issue" + else if type == "wiki" + type = "wiki_page" + + file = dataTransfer.files[0] + + return if !attachmentsService.validate(file) + + attachmentsService.upload( + file, + $model.$modelValue.id, + $model.$modelValue.project, + type + ).then (result) -> + textarea = $el.find('textarea') + + if taiga.isImage(result.get('name')) + url = '![' + result.get('name') + '](' + result.get('url') + ')' + else + url = '[' + result.get('name') + '](' + result.get('url') + ')' + + $.markItUp({ replaceWith: url }) + + textarea.removeClass('in-progress') + + return { + link: link + restrict: "EA" + require: "ngModel" + } + +module.directive("tgEditableWysiwyg", ["tgAttachmentsService", EditableWysiwyg]) ############################################################################# diff --git a/app/coffee/modules/common/filters.coffee b/app/coffee/modules/common/filters.coffee index a6ea87ec..7590b45c 100644 --- a/app/coffee/modules/common/filters.coffee +++ b/app/coffee/modules/common/filters.coffee @@ -68,3 +68,9 @@ momentFromNow = -> return "" module.filter("momentFromNow", momentFromNow) + + +sizeFormat = => + return @.taiga.sizeFormat + +module.filter("sizeFormat", sizeFormat) diff --git a/app/coffee/modules/common/lightboxes.coffee b/app/coffee/modules/common/lightboxes.coffee index 280bcd7d..34b321a4 100644 --- a/app/coffee/modules/common/lightboxes.coffee +++ b/app/coffee/modules/common/lightboxes.coffee @@ -27,6 +27,7 @@ module = angular.module("taigaCommon") bindOnce = @.taiga.bindOnce timeout = @.taiga.timeout debounce = @.taiga.debounce +sizeFormat = @.taiga.sizeFormat ############################################################################# ## Common Lightbox Services @@ -265,13 +266,30 @@ module.directive("tgBlockingMessageInput", ["$log", "$tgTemplate", "$compile", B ## Create/Edit Userstory Lightbox Directive ############################################################################# -CreateEditUserstoryDirective = ($repo, $model, $rs, $rootScope, lightboxService, $loading, $translate) -> +CreateEditUserstoryDirective = ($repo, $model, $rs, $rootScope, lightboxService, $loading, $translate, $confirm, $q, attachmentsService) -> link = ($scope, $el, attrs) -> + $scope.createEditUs = {} $scope.isNew = true + attachmentsToAdd = Immutable.List() + attachmentsToDelete = Immutable.List() + + resetAttachments = () -> + attachmentsToAdd = Immutable.List() + attachmentsToDelete = Immutable.List() + + $scope.addAttachment = (attachment) -> + attachmentsToAdd = attachmentsToAdd.push(attachment) + + $scope.deleteAttachment = (attachment) -> + attachmentsToDelete = attachmentsToDelete.push(attachment) + $scope.$on "usform:new", (ctx, projectId, status, statusList) -> $scope.isNew = true $scope.usStatusList = statusList + $scope.attachments = Immutable.List() + + resetAttachments() $scope.us = $model.make_model("userstories", { project: projectId @@ -293,10 +311,13 @@ CreateEditUserstoryDirective = ($repo, $model, $rs, $rootScope, lightboxService, lightboxService.open($el) - $scope.$on "usform:edit", (ctx, us) -> + $scope.$on "usform:edit", (ctx, us, attachments) -> $scope.us = us + $scope.attachments = Immutable.fromJS(attachments) $scope.isNew = false + resetAttachments() + # Update texts for edition $el.find(".button-green").html($translate.instant("COMMON.SAVE")) $el.find(".title").html($translate.instant("LIGHTBOX.CREATE_EDIT_US.EDIT_US")) @@ -321,6 +342,18 @@ CreateEditUserstoryDirective = ($repo, $model, $rs, $rootScope, lightboxService, lightboxService.open($el) + createAttachments = (obj) -> + promises = _.map attachmentsToAdd.toJS(), (attachment) -> + attachmentsService.upload(attachment.file, obj.id, $scope.us.project, 'us') + + return $q.all(promises) + + deleteAttachments = (obj) -> + promises = _.map attachmentsToDelete.toJS(), (attachment) -> + return attachmentsService.delete("us", attachment.id) + + return $q.all(promises) + submit = debounce 2000, (event) => event.preventDefault() @@ -339,6 +372,12 @@ CreateEditUserstoryDirective = ($repo, $model, $rs, $rootScope, lightboxService, promise = $repo.save($scope.us) broadcastEvent = "usform:edit:success" + promise.then (data) -> + createAttachments(data) + deleteAttachments(data) + + return data + promise.then (data) -> currentLoading.finish() lightboxService.close($el) @@ -380,6 +419,9 @@ module.directive("tgLbCreateEditUserstory", [ "lightboxService", "$tgLoading", "$translate", + "$tgConfirm", + "$q", + "tgAttachmentsService", CreateEditUserstoryDirective ]) @@ -632,30 +674,14 @@ module.directive("tgLbWatchers", ["$tgRepo", "lightboxService", "lightboxKeyboar ## Attachment Preview Lighbox ############################################################################# -AttachmentPreviewLightboxDirective = ($repo, lightboxService, lightboxKeyboardNavigationService, $template, $compile) -> +AttachmentPreviewLightboxDirective = (lightboxService, $template, $compile) -> link = ($scope, $el, attrs) -> - template = $template.get("common/lightbox/lightbox-attachment-preview.html", true) - - $scope.$on "attachment:preview", (event, attachment) -> - lightboxService.open($el) - render(attachment) - - $scope.$on "$destroy", -> - $el.off() - - render = (attachment) -> - ctx = { - url: attachment.url, - title: attachment.description, - name: attachment.name - } - - html = template(ctx) - html = $compile(html)($scope) - $el.html(html) + lightboxService.open($el) return { - link: link + templateUrl: 'common/lightbox/lightbox-attachment-preview.html', + link: link, + scope: true } -module.directive("tgLbAttachmentPreview", ["$tgRepo", "lightboxService", "lightboxKeyboardNavigationService", "$tgTemplate", "$compile", AttachmentPreviewLightboxDirective]) +module.directive("tgLbAttachmentPreview", ["lightboxService", "$tgTemplate", "$compile", AttachmentPreviewLightboxDirective]) diff --git a/app/coffee/modules/issues/lightboxes.coffee b/app/coffee/modules/issues/lightboxes.coffee index 70d805e1..de23b12e 100644 --- a/app/coffee/modules/issues/lightboxes.coffee +++ b/app/coffee/modules/issues/lightboxes.coffee @@ -32,12 +32,15 @@ module = angular.module("taigaIssues") ## Issue Create Lightbox Directive ############################################################################# -CreateIssueDirective = ($repo, $confirm, $rootscope, lightboxService, $loading) -> +CreateIssueDirective = ($repo, $confirm, $rootscope, lightboxService, $loading, $q, attachmentsService) -> link = ($scope, $el, $attrs) -> form = $el.find("form").checksley() $scope.issue = {} + $scope.attachments = Immutable.List() $scope.$on "issueform:new", (ctx, project)-> + attachmentsToAdd = Immutable.List() + $el.find(".tag-input").val("") lightboxService.open($el) @@ -55,6 +58,21 @@ CreateIssueDirective = ($repo, $confirm, $rootscope, lightboxService, $loading) $scope.$on "$destroy", -> $el.off() + + createAttachments = (obj) -> + promises = _.map attachmentsToAdd.toJS(), (attachment) -> + return attachmentsService.upload(attachment.file, obj.id, $scope.issue.project, 'issue') + + return $q.all(promises) + + attachmentsToAdd = Immutable.List() + + resetAttachments = () -> + attachmentsToAdd = Immutable.List() + + $scope.addAttachment = (attachment) -> + attachmentsToAdd = attachmentsToAdd.push(attachment) + submit = debounce 2000, (event) => event.preventDefault() @@ -67,6 +85,9 @@ CreateIssueDirective = ($repo, $confirm, $rootscope, lightboxService, $loading) promise = $repo.create("issues", $scope.issue) + promise.then (data) -> + return createAttachments(data) + promise.then (data) -> currentLoading.finish() $rootscope.$broadcast("issueform:new:success", data) @@ -85,7 +106,7 @@ CreateIssueDirective = ($repo, $confirm, $rootscope, lightboxService, $loading) return {link:link} -module.directive("tgLbCreateIssue", ["$tgRepo", "$tgConfirm", "$rootScope", "lightboxService", "$tgLoading", +module.directive("tgLbCreateIssue", ["$tgRepo", "$tgConfirm", "$rootScope", "lightboxService", "$tgLoading", "$q", "tgAttachmentsService", CreateIssueDirective]) diff --git a/app/coffee/modules/kanban/main.coffee b/app/coffee/modules/kanban/main.coffee index 878a9c7c..46af5382 100644 --- a/app/coffee/modules/kanban/main.coffee +++ b/app/coffee/modules/kanban/main.coffee @@ -416,7 +416,7 @@ module.directive("tgKanbanArchivedStatusIntro", ["$translate", KanbanArchivedSta ## Kanban User Story Directive ############################################################################# -KanbanUserstoryDirective = ($rootscope, $loading, $rs) -> +KanbanUserstoryDirective = ($rootscope, $loading, $rs, $rs2) -> link = ($scope, $el, $attrs, $model) -> $el.disableSelection() @@ -440,8 +440,9 @@ KanbanUserstoryDirective = ($rootscope, $loading, $rs) -> us = $model.$modelValue $rs.userstories.getByRef(us.project, us.ref).then (editingUserStory) => - $rootscope.$broadcast("usform:edit", editingUserStory) - currentLoading.finish() + $rs2.attachments.list("us", us.id, us.project).then (attachments) => + $rootscope.$broadcast("usform:edit", editingUserStory, attachments.toJS()) + currentLoading.finish() $scope.getTemplateUrl = () -> if $scope.us.isPlaceholder @@ -458,7 +459,7 @@ KanbanUserstoryDirective = ($rootscope, $loading, $rs) -> require: "ngModel" } -module.directive("tgKanbanUserstory", ["$rootScope", "$tgLoading", "$tgResources", KanbanUserstoryDirective]) +module.directive("tgKanbanUserstory", ["$rootScope", "$tgLoading", "$tgResources", "tgResources", KanbanUserstoryDirective]) ############################################################################# ## Kanban Squish Column Directive diff --git a/app/coffee/modules/related-tasks.coffee b/app/coffee/modules/related-tasks.coffee index 172e1b24..fb2ce8da 100644 --- a/app/coffee/modules/related-tasks.coffee +++ b/app/coffee/modules/related-tasks.coffee @@ -193,10 +193,8 @@ RelatedTaskCreateFormDirective = ($repo, $compile, $confirm, $tgmodel, $loading, return {link: link} module.directive("tgRelatedTaskCreateForm", ["$tgRepo", "$compile", "$tgConfirm", "$tgModel", "$tgLoading", "$tgAnalytics", "$tgTemplate", RelatedTaskCreateFormDirective]) -RelatedTaskCreateButtonDirective = ($repo, $compile, $confirm, $tgmodel) -> - template = _.template(""" - - """) +RelatedTaskCreateButtonDirective = ($repo, $compile, $confirm, $tgmodel, $template) -> + template = $template.get("common/components/add-button.html", true) link = ($scope, $el, $attrs) -> $scope.$watch "project", (val) -> @@ -207,14 +205,14 @@ RelatedTaskCreateButtonDirective = ($repo, $compile, $confirm, $tgmodel) -> else $el.html("") - $el.on "click", ".icon", (event)-> + $el.on "click", ".add-button", (event)-> $scope.$emit("related-tasks:add-new-clicked") $scope.$on "$destroy", -> $el.off() return {link: link} -module.directive("tgRelatedTaskCreateButton", ["$tgRepo", "$compile", "$tgConfirm", "$tgModel", RelatedTaskCreateButtonDirective]) +module.directive("tgRelatedTaskCreateButton", ["$tgRepo", "$compile", "$tgConfirm", "$tgModel", "$tgTemplate", RelatedTaskCreateButtonDirective]) RelatedTasksDirective = ($repo, $rs, $rootscope) -> link = ($scope, $el, $attrs) -> diff --git a/app/coffee/modules/resources.coffee b/app/coffee/modules/resources.coffee index 1396f64f..77677ca7 100644 --- a/app/coffee/modules/resources.coffee +++ b/app/coffee/modules/resources.coffee @@ -213,7 +213,6 @@ module.run([ "$tgIssuesResourcesProvider", "$tgWikiResourcesProvider", "$tgSearchResourcesProvider", - "$tgAttachmentsResourcesProvider", "$tgMdRenderResourcesProvider", "$tgHistoryResourcesProvider", "$tgKanbanResourcesProvider", diff --git a/app/coffee/modules/taskboard/lightboxes.coffee b/app/coffee/modules/taskboard/lightboxes.coffee index 0c88342b..9cc979d2 100644 --- a/app/coffee/modules/taskboard/lightboxes.coffee +++ b/app/coffee/modules/taskboard/lightboxes.coffee @@ -26,10 +26,36 @@ taiga = @.taiga bindOnce = @.taiga.bindOnce debounce = @.taiga.debounce -CreateEditTaskDirective = ($repo, $model, $rs, $rootscope, $loading, lightboxService, $translate) -> +CreateEditTaskDirective = ($repo, $model, $rs, $rootscope, $loading, lightboxService, $translate, $q, attachmentsService) -> link = ($scope, $el, attrs) -> $scope.isNew = true + attachmentsToAdd = Immutable.List() + attachmentsToDelete = Immutable.List() + + resetAttachments = () -> + attachmentsToAdd = Immutable.List() + attachmentsToDelete = Immutable.List() + + $scope.addAttachment = (attachment) -> + attachmentsToAdd = attachmentsToAdd.push(attachment) + + $scope.deleteAttachment = (attachment) -> + attachmentsToDelete = attachmentsToDelete.push(attachment) + + createAttachments = (obj) -> + promises = _.map attachmentsToAdd.toJS(), (attachment) -> + attachmentsService.upload(attachment.file, obj.id, $scope.task.project, 'task') + + return $q.all(promises) + + deleteAttachments = (obj) -> + console.log attachmentsToDelete.toJS() + promises = _.map attachmentsToDelete.toJS(), (attachment) -> + return attachmentsService.delete("task", attachment.id) + + return $q.all(promises) + $scope.$on "taskform:new", (ctx, sprintId, usId) -> $scope.task = { project: $scope.projectId @@ -41,6 +67,9 @@ CreateEditTaskDirective = ($repo, $model, $rs, $rootscope, $loading, lightboxSer tags: [] } $scope.isNew = true + $scope.attachments = Immutable.List() + + resetAttachments() # Update texts for creation create = $translate.instant("COMMON.CREATE") @@ -52,10 +81,14 @@ CreateEditTaskDirective = ($repo, $model, $rs, $rootscope, $loading, lightboxSer $el.find(".tag-input").val("") lightboxService.open($el) - $scope.$on "taskform:edit", (ctx, task) -> + $scope.$on "taskform:edit", (ctx, task, attachments) -> $scope.task = task $scope.isNew = false + $scope.attachments = Immutable.fromJS(attachments) + + resetAttachments() + # Update texts for edition save = $translate.instant("COMMON.SAVE") edit = $translate.instant("LIGHTBOX.CREATE_EDIT_TASK.ACTION_EDIT") @@ -83,6 +116,12 @@ CreateEditTaskDirective = ($repo, $model, $rs, $rootscope, $loading, lightboxSer promise = $repo.save($scope.task) broadcastEvent = "taskform:edit:success" + promise.then (data) -> + createAttachments(data) + deleteAttachments(data) + + return data + currentLoading = $loading() .target(submitButton) .start() @@ -155,7 +194,9 @@ module.directive("tgLbCreateEditTask", [ "$rootScope", "$tgLoading", "lightboxService", - "$translate" + "$translate", + "$q", + "tgAttachmentsService", CreateEditTaskDirective ]) diff --git a/app/coffee/modules/taskboard/main.coffee b/app/coffee/modules/taskboard/main.coffee index ab45851d..44a216d3 100644 --- a/app/coffee/modules/taskboard/main.coffee +++ b/app/coffee/modules/taskboard/main.coffee @@ -307,7 +307,7 @@ module.directive("tgTaskboard", ["$rootScope", TaskboardDirective]) ## Taskboard Task Directive ############################################################################# -TaskboardTaskDirective = ($rootscope, $loading, $rs) -> +TaskboardTaskDirective = ($rootscope, $loading, $rs, $rs2) -> link = ($scope, $el, $attrs, $model) -> $el.disableSelection() @@ -330,14 +330,16 @@ TaskboardTaskDirective = ($rootscope, $loading, $rs) -> .start() task = $scope.task + $rs.tasks.getByRef(task.project, task.ref).then (editingTask) => - $rootscope.$broadcast("taskform:edit", editingTask) - currentLoading.finish() + $rs2.attachments.list("task", editingTask.id, editingTask.project).then (attachments) => + $rootscope.$broadcast("taskform:edit", editingTask, attachments.toJS()) + currentLoading.finish() return {link:link} -module.directive("tgTaskboardTask", ["$rootScope", "$tgLoading", "$tgResources", TaskboardTaskDirective]) +module.directive("tgTaskboardTask", ["$rootScope", "$tgLoading", "$tgResources", "tgResources", TaskboardTaskDirective]) ############################################################################# ## Taskboard Squish Column Directive diff --git a/app/coffee/utils.coffee b/app/coffee/utils.coffee index 45bb4323..81276e6d 100644 --- a/app/coffee/utils.coffee +++ b/app/coffee/utils.coffee @@ -195,6 +195,21 @@ _.mixin delete obj[key]; obj , obj).value() +isImage = (name) -> + return name.match(/\.(jpe?g|png|gif|gifv|webm)/i) != null + +patch = (oldImmutable, newImmutable) -> + pathObj = {} + + newImmutable.forEach (newValue, key) -> + if newValue != oldImmutable.get(key) + if newValue.toJS + pathObj[key] = newValue.toJS() + else + pathObj[key] = newValue + + return pathObj + taiga = @.taiga taiga.nl2br = nl2br taiga.bindMethods = bindMethods @@ -218,3 +233,5 @@ taiga.sizeFormat = sizeFormat taiga.stripTags = stripTags taiga.replaceTags = replaceTags taiga.defineImmutableProperty = defineImmutableProperty +taiga.isImage = isImage +taiga.patch = patch diff --git a/app/images/attachment-gallery.png b/app/images/attachment-gallery.png new file mode 100644 index 00000000..b8189c44 Binary files /dev/null and b/app/images/attachment-gallery.png differ diff --git a/app/locales/taiga/locale-en.json b/app/locales/taiga/locale-en.json index 2a6b39c1..37455848 100644 --- a/app/locales/taiga/locale-en.json +++ b/app/locales/taiga/locale-en.json @@ -361,6 +361,7 @@ "DEPRECATED": "(deprecated)", "DEPRECATED_FILE": "Deprecated?", "ADD": "Add new attachment. {{maxFileSizeMsg}}", + "DROP": "Drop attachments here!", "MAX_FILE_SIZE": "[Max. size: {{maxFileSize}}]", "SHOW_DEPRECATED": "+ show deprecated atachments", "HIDE_DEPRECATED": "- hide deprecated atachments", @@ -371,6 +372,7 @@ "TITLE_LIGHTBOX_DELETE_ATTACHMENT": "Delete attachment...", "MSG_LIGHTBOX_DELETE_ATTACHMENT": "the attachment '{{fileName}}'", "ERROR_DELETE_ATTACHMENT": "We have not been able to delete: {{errorMessage}}", + "ERROR_MAX_SIZE_EXCEEDED": "'{{fileName}}' ({{fileSize}}) is too heavy for our Oompa Loompas, try it with a smaller than ({{maxFileSize}})", "FIELDS": { "IS_DEPRECATED": "is deprecated" } diff --git a/app/modules/attachments/attachment-gallery.scss b/app/modules/attachments/attachment-gallery.scss new file mode 100644 index 00000000..029b29a9 --- /dev/null +++ b/app/modules/attachments/attachment-gallery.scss @@ -0,0 +1,70 @@ +.attachment-gallery { + display: flex; + flex-basis: 25%; + flex-wrap: wrap; + justify-content: flex-start; + margin-top: 1rem; + .single-attachment { + margin-bottom: .5rem; + margin-right: .5rem; + max-width: 200px; + &:hover { + .icon-delete { + opacity: 1; + } + } + .attachment-image { + display: inline-block; + } + img { + height: 150px; + margin-bottom: .2rem; + width: 200px; + &:hover { + filter: saturate(150%) hue-rotate(60deg); + transition: all .3s cubic-bezier(.01, .7, 1, 1); + } + } + &.deprecated { + img { + opacity: .5; + } + .attachment-name { + color: $gray-light; + } + } + .attachment-data { + align-content: center; + display: flex; + justify-content: space-between; + } + .attachment-name { + @extend %light; + @include ellipsis(175px); + display: inline-block; + } + .icon-delete { + color: $red-light; + margin-left: auto; + opacity: 0; + transition: opacity .3s ease-in; + transition-delay: .2s; + &:hover { + color: $red; + } + } + .loading-container { + align-items: center; + display: flex; + height: 150px; + justify-content: center; + margin: 0 .5rem .5rem 0; + width: 200px; + } + .loading-spinner { + margin: 0 auto; + max-height: 3rem; + max-width: 3rem; + } + } +} diff --git a/app/modules/attachments/attachment-list.scss b/app/modules/attachments/attachment-list.scss new file mode 100644 index 00000000..dc2e21db --- /dev/null +++ b/app/modules/attachments/attachment-list.scss @@ -0,0 +1,103 @@ +.attachment-list { + .single-attachment { + align-items: center; + border-bottom: 1px solid $whitish; + display: flex; + padding: .5rem 0 .5rem .5rem; + position: relative; + &:hover { + .settings { + opacity: 1; + transition: opacity .2s ease-in; + } + } + &.deprecated { + color: $gray-light; + .attachment-name a { + color: $gray-light; + } + } + } + .attachment-name { + @include ellipsis(90%); + flex-basis: 25%; + flex-grow: 1; + flex-shrink: 0; + padding-right: 1rem; + } + .attachment-comments, + .editable-attachment-comment { + flex: 2; + flex-basis: 50%; + margin-right: .5rem; + span { + color: $gray; + } + } + .attachment-size { + flex-basis: 125px; + flex-grow: 0; + flex-shrink: 0; + } + .attachment-settings { + align-items: center; + display: flex; + flex-basis: 10%; + flex-grow: 0; + flex-shrink: 0; + justify-content: space-around; + margin-left: auto; + .settings, + .editable-settings { + @extend %large; + color: $gray-light; + &:hover { + color: $primary; + } + } + .settings { + opacity: 0; + } + .editable-settings { + opacity: 1; + } + .icon-delete { + &:hover { + color: $red; + } + } + .icon-drag-v { + cursor: move; + } + } + .icon-delete { + @extend %large; + color: $gray-light; + &:hover { + color: $red; + } + } + .editable-attachment-deprecated { + display: flex; + padding-left: 1rem; + span { + color: $gray-light; + } + input { + margin-right: .2rem; + vertical-align: middle; + &:checked+span { + color: $grayer; + } + } + } + .percentage { + background: rgba($primary, .1); + bottom: 0; + height: 40px; + left: 0; + position: absolute; + top: 0; + width: 45%; + } +} diff --git a/app/modules/attachments/attachments.scss b/app/modules/attachments/attachments.scss new file mode 100644 index 00000000..12447e7f --- /dev/null +++ b/app/modules/attachments/attachments.scss @@ -0,0 +1,120 @@ +.attachments { + margin-bottom: 2rem; +} + +.attachments-header { + align-content: space-between; + align-items: center; + background: $whitish; + display: flex; + justify-content: space-between; + .attachments-title { + @extend %medium; + @extend %bold; + color: $grayer; + padding: 0 1rem; + } + .options { + display: flex; + } + label { + cursor: pointer; + margin-left: 1rem; + &.add-attachment-button { + background: $gray; + border: 0; + display: inline-block; + padding: .5rem; + transition: background .25s; + &:hover { + background: $primary-light; + } + } + svg { + fill: $white; + height: 1.25rem; + margin-bottom: -.2rem; + width: 1.25rem; + } + } + button { + margin-right: .2rem; + &:hover, + &.is-active { + svg { + fill: $primary-light; + } + } + svg { + fill: $gray-light; + height: 1.6rem; + margin-bottom: -.2rem; + width: 1.6rem; + } + } + input { + display: none; + } +} + +.attachments-empty { + @extend %large; + @extend %bold; + border: 3px dashed $whitish; + color: $gray-light; + margin-top: .5rem; + padding: 1rem; + text-align: center; +} + +.single-attachment { + @extend %small; + &.ui-sortable-helper { + background: lighten($primary, 60%); + box-shadow: 1px 1px 10px rgba($black, .1); + transition: background .2s ease-in; + } + &.sortable-placeholder { + background: $whitish; + height: 40px; + } + .attachment-name { + @extend %bold; + padding-right: 1rem; + .icon { + margin-right: .25rem; + } + svg { + height: 1.2rem; + width: 1.2rem; + } + } + .attachment-size { + color: $gray-light; + } +} + +.more-attachments { + @extend %small; + border-bottom: 1px solid $gray-light; + display: block; + padding: 1rem 0 1rem 1rem; + span { + color: $gray-light; + } + .more-attachments-num { + color: $primary; + margin-left: .5rem; + } + &:hover { + background: lighten($primary, 60%); + transition: background .2s ease-in; + } +} + +.attachment-preview { + img { + max-height: 80vh; + max-width: 80vw; + } +} diff --git a/app/modules/components/attachment-link/attachment-link.directive.coffee b/app/modules/components/attachment-link/attachment-link.directive.coffee new file mode 100644 index 00000000..50cd109e --- /dev/null +++ b/app/modules/components/attachment-link/attachment-link.directive.coffee @@ -0,0 +1,45 @@ +### +# 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: attachment-link.directive.coffee +### + +AttachmentLinkDirective = ($parse, lightboxFactory) -> + link = (scope, el, attrs) -> + attachment = $parse(attrs.tgAttachmentLink)(scope) + + el.on "click", (event) -> + if taiga.isImage(attachment.getIn(['file', 'name'])) + event.preventDefault() + + scope.$apply -> + lightboxFactory.create('tg-lb-attachment-preview', { + class: 'lightbox lightbox-block' + }, { + file: attachment.get('file') + }) + + scope.$on "$destroy", -> el.off() + return { + link: link + } + +AttachmentLinkDirective.$inject = [ + "$parse", + "tgLightboxFactory" +] + +angular.module("taigaComponents").directive("tgAttachmentLink", AttachmentLinkDirective) diff --git a/app/modules/components/attachment/attachment-gallery.directive.coffee b/app/modules/components/attachment/attachment-gallery.directive.coffee new file mode 100644 index 00000000..2214a893 --- /dev/null +++ b/app/modules/components/attachment/attachment-gallery.directive.coffee @@ -0,0 +1,39 @@ +### +# 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: attachment-gallery.directive.coffee +### + +AttachmentGalleryDirective = () -> + link = (scope, el, attrs, ctrl) -> + + return { + scope: {}, + bindToController: { + attachment: "=", + onDelete: "&", + onUpdate: "&", + type: "=" + }, + controller: "Attachment", + controllerAs: "vm", + templateUrl: "components/attachment/attachment-gallery.html", + link: link + } + +AttachmentGalleryDirective.$inject = [] + +angular.module("taigaComponents").directive("tgAttachmentGallery", AttachmentGalleryDirective) diff --git a/app/modules/components/attachment/attachment-gallery.jade b/app/modules/components/attachment/attachment-gallery.jade new file mode 100644 index 00000000..a911f56a --- /dev/null +++ b/app/modules/components/attachment/attachment-gallery.jade @@ -0,0 +1,36 @@ +.single-attachment( + ng-class="{deprecated: vm.attachment.getIn(['file', 'is_deprecated'])}", + ng-if="vm.attachment.getIn(['file', 'id'])", +) + a.attachment-image( + tg-attachment-link="vm.attachment" + href="{{::vm.attachment.getIn(['file', 'url'])}}" + title="{{::vm.attachment.getIn(['file', 'name'])}}" + target="_blank" + download="{{::vm.attachment.getIn(['file', 'name'])}}" + ) + img( + alt="{{::vm.attachment.getIn(['file', 'name'])}}" + ng-src="{{::vm.attachment.getIn(['file', 'thumbnail_card_url'])}}" + ng-if="vm.attachment.getIn(['file', 'thumbnail_card_url'])" + ) + img( + alt="{{::vm.attachment.getIn(['file', 'name'])}}" + src="/#{v}/images/attachment-gallery.png" + ng-if="!vm.attachment.getIn(['file', 'thumbnail_card_url'])" + ) + .attachment-data + a.attachment-name( + tg-attachment-link="vm.attachment" + href="{{::vm.attachment.getIn(['file', 'url'])}}" + title="{{::vm.attachment.get(['file', 'name'])}}" + target="_blank" + download="{{::vm.attachment.getIn(['file', 'name'])}}" + ) + span {{::vm.attachment.getIn(['file', 'name'])}} + + a.icon-delete( + href="" + title="{{'COMMON.DELETE' | translate}}" + ng-click="vm.delete()" + ) diff --git a/app/modules/components/attachment/attachment.controller.coffee b/app/modules/components/attachment/attachment.controller.coffee new file mode 100644 index 00000000..ebbbac03 --- /dev/null +++ b/app/modules/components/attachment/attachment.controller.coffee @@ -0,0 +1,59 @@ +### +# 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: attchment.controller.coffee +### + +class AttachmentController + @.$inject = [ + 'tgAttachmentsService', + '$translate' + ] + + constructor: (@attachmentsService, @translate) -> + @.form = {} + @.form.description = @.attachment.getIn(['file', 'description']) + @.form.is_deprecated = @.attachment.get(['file', 'is_deprecated']) + + @.loading = false + + @.title = @translate.instant("ATTACHMENT.TITLE", { + fileName: @.attachment.get('name'), + date: moment(@.attachment.get('created_date')).format(@translate.instant("ATTACHMENT.DATE")) + }) + + editMode: (mode) -> + @.attachment = @.attachment.set('editable', mode) + + delete: () -> + @.onDelete({attachment: @.attachment}) + + save: () -> + @.attachment = @.attachment.set('loading', true) + + attachment = @.attachment.merge({ + editable: false, + loading: false + }) + + attachment = attachment.mergeIn(['file'], { + description: @.form.description, + is_deprecated: !!@.form.is_deprecated + }) + + @.onUpdate({attachment: attachment}) + +angular.module('taigaComponents').controller('Attachment', AttachmentController) diff --git a/app/modules/components/attachment/attachment.controller.spec.coffee b/app/modules/components/attachment/attachment.controller.spec.coffee new file mode 100644 index 00000000..e678de1d --- /dev/null +++ b/app/modules/components/attachment/attachment.controller.spec.coffee @@ -0,0 +1,140 @@ +### +# 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: attchment.controller.spec.coffee +### + +describe "AttachmentController", -> + $provide = null + $controller = null + scope = null + mocks = {} + + _mockAttachmentsService = -> + mocks.attachmentsService = {} + + $provide.value("tgAttachmentsService", mocks.attachmentsService) + + _mockTranslate = -> + mocks.translate = { + instant: sinon.stub() + } + + $provide.value("$translate", mocks.translate) + + _mocks = -> + module (_$provide_) -> + $provide = _$provide_ + + _mockAttachmentsService() + _mockTranslate() + + return null + + _inject = -> + inject (_$controller_, $rootScope) -> + $controller = _$controller_ + scope = $rootScope.$new() + + _setup = -> + _mocks() + _inject() + + beforeEach -> + module "taigaComponents" + + _setup() + + it "change edit mode", () -> + attachment = Immutable.fromJS({ + file: { + description: 'desc', + is_deprecated: false + } + }) + + ctrl = $controller("Attachment", { + $scope: scope + }, { + attachment : attachment + }) + + ctrl.editable = false + ctrl.editMode(true) + + expect(ctrl.attachment.get('editable')).to.be.true + + it "delete", () -> + attachment = Immutable.fromJS({ + file: { + description: 'desc', + is_deprecated: false + } + }) + + ctrl = $controller("Attachment", { + $scope: scope + }, { + attachment : attachment + }) + + ctrl.onDelete = sinon.spy() + + onDelete = sinon.match (value) -> + return value.attachment == attachment + , "onDelete" + + ctrl.delete() + + expect(ctrl.onDelete).to.be.calledWith(onDelete) + + it "save", () -> + attachment = Immutable.fromJS({ + file: { + description: 'desc', + is_deprecated: false + }, + loading: false, + editable: false + }) + + ctrl = $controller("Attachment", { + $scope: scope + }, { + attachment : attachment + }) + + ctrl.onUpdate = sinon.spy() + + onUpdate = sinon.match (value) -> + value = value.attachment.toJS() + + return ( + value.file.description == "ok" && + value.file.is_deprecated + ) + , "onUpdate" + + ctrl.form = { + description: "ok" + is_deprecated: true + } + + ctrl.save() + + attachment = ctrl.attachment.toJS() + + expect(ctrl.onUpdate).to.be.calledWith(onUpdate) diff --git a/app/modules/components/attachment/attachment.directive.coffee b/app/modules/components/attachment/attachment.directive.coffee new file mode 100644 index 00000000..94202b8a --- /dev/null +++ b/app/modules/components/attachment/attachment.directive.coffee @@ -0,0 +1,39 @@ +### +# 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: attachment.directive.coffee +### + +AttachmentDirective = () -> + link = (scope, el, attrs, ctrl) -> + + return { + scope: {}, + bindToController: { + attachment: "=", + onDelete: "&", + onUpdate: "&", + type: "=" + }, + controller: "Attachment", + controllerAs: "vm", + templateUrl: "components/attachment/attachment.html", + link: link + } + +AttachmentDirective.$inject = [] + +angular.module("taigaComponents").directive("tgAttachment", AttachmentDirective) diff --git a/app/modules/components/attachment/attachment.jade b/app/modules/components/attachment/attachment.jade new file mode 100644 index 00000000..32df1083 --- /dev/null +++ b/app/modules/components/attachment/attachment.jade @@ -0,0 +1,80 @@ +form.single-attachment( + ng-class="{deprecated: vm.attachment.getIn(['file', 'is_deprecated'])}", + ng-if="vm.attachment.getIn(['file', 'id'])", + ng-submit="vm.save()" +) + + .attachment-name + a( + tg-attachment-link="vm.attachment" + href="{{::vm.attachment.getIn(['file', 'url'])}}" + title="{{::vm.attachment.get(['file', 'name'])}}" + target="_blank" + download="{{::vm.attachment.getIn(['file', 'name'])}}" + ) + span.icon + include ../../../svg/attachment.svg + span {{::vm.attachment.getIn(['file', 'name'])}} + + .attachment-comments(ng-if="!vm.attachment.get('editable') && vm.attachment.getIn(['file', 'description'])") + span.deprecated-file(ng-if="vm.attachment.getIn(['file', 'is_deprecated'])") {{'ATTACHMENT.DEPRECATED' | translate}} + span {{vm.attachment.getIn(['file', 'description'])}} + + .attachment-size + span {{::vm.attachment.getIn(['file', 'size']) | sizeFormat}} + + .editable.editable-attachment-comment(ng-if="vm.attachment.get('editable')") + input( + type="text", + name="description", + maxlength="140", + ng-model="vm.form.description", + tg-auto-select, + ng-keydown="$event.which === 27 && vm.editMode(false)" + placeholder="{{'ATTACHMENT.DESCRIPTION' | translate}}" + ) + + .editable.editable-attachment-deprecated(ng-if="vm.attachment.get('editable')") + input( + type="checkbox" + ng-model="vm.form.is_deprecated" + name="is-deprecated" + id="attach-{{::vm.attachment.getIn(['file', 'id'])}}-is-deprecated" + ) + label.is_deprecated( + for="attach-{{::vm.attachment.getIn(['file', 'id'])}}-is-deprecated" + translate="{{'ATTACHMENT.DEPRECATED_FILE' | translate}}") + + .attachment-settings(ng-if="vm.attachment.get('editable')") + div(tg-loading="vm.attachment.get('loading')") + a.editable-settings.icon.icon-floppy( + href="" + title="{{'COMMON.SAVE' | translate}}" + ng-click="vm.save()" + ) + + div + a.editable-settings.icon.icon-delete( + href="" + title="{{'COMMON.CANCEL' | translate}}" + ng-click="vm.editMode(false)" + ) + + .attachment-settings( + ng-if="!vm.attachment.get('editable')" + tg-check-permission="modify_{{vm.type}}" + ) + a.settings.icon.icon-edit( + href="" + title="{{'COMMON.EDIT' | translate}}" + ng-click="vm.editMode(true)" + ) + a.settings.icon.icon-delete( + href="" + title="{{'COMMON.DELETE' | translate}}" + ng-click="vm.delete()" + ) + a.settings.icon.icon-drag-v( + href="" + title="{{'COMMON.DRAG' | translate}}" + ) diff --git a/app/modules/components/attachments-drop/attachments-drop.directive.coffee b/app/modules/components/attachments-drop/attachments-drop.directive.coffee new file mode 100644 index 00000000..7fb995a5 --- /dev/null +++ b/app/modules/components/attachments-drop/attachments-drop.directive.coffee @@ -0,0 +1,46 @@ +### +# 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: attachments-drop.directive.coffee +### + +AttachmentsDropDirective = ($parse) -> + link = (scope, el, attrs) -> + eventAttr = $parse(attrs.tgAttachmentsDrop) + + el.on 'dragover', (e) -> + e.preventDefault() + return false + + el.on 'drop', (e) -> + e.stopPropagation() + e.preventDefault() + + dataTransfer = e.dataTransfer || (e.originalEvent && e.originalEvent.dataTransfer); + + scope.$apply () -> eventAttr(scope, {files: dataTransfer.files}) + + scope.$on "$destroy", -> el.off() + + return { + link: link + } + +AttachmentsDropDirective.$inject = [ + "$parse" +] + +angular.module("taigaComponents").directive("tgAttachmentsDrop", AttachmentsDropDirective) diff --git a/app/modules/components/attachments-full/attachments-full.controller.coffee b/app/modules/components/attachments-full/attachments-full.controller.coffee new file mode 100644 index 00000000..e2ae9361 --- /dev/null +++ b/app/modules/components/attachments-full/attachments-full.controller.coffee @@ -0,0 +1,150 @@ +### +# 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: attchments-full.controller.coffee +### + +sizeFormat = @.taiga.sizeFormat + +class AttachmentsFullController + @.$inject = [ + "tgAttachmentsService", + "$rootScope", + "$translate", + "$tgConfirm", + "$tgConfig", + "$tgStorage" + ] + + constructor: (@attachmentsService, @rootScope, @translate, @confirm, @config, @storage) -> + @.deprecatedsVisible = false + @.uploadingAttachments = [] + + @.mode = @storage.get('attachment-mode', 'list') + + @.maxFileSize = @config.get("maxUploadFileSize", null) + @.maxFileSize = sizeFormat(@.maxFileSize) if @.maxFileSize + @.maxFileSizeMsg = if @.maxFileSize then @translate.instant("ATTACHMENT.MAX_UPLOAD_SIZE", {maxFileSize: @.maxFileSize}) else "" + + loadAttachments: -> + @attachmentsService.list(@.type, @.objId, @.projectId).then (files) => + @.attachments = files.map (file) -> + attachment = Immutable.Map() + + return attachment.merge({ + loading: false, + editable: false, + file: file + }) + + @.generate() + + setMode: (mode) -> + @.mode = mode + + @storage.set('attachment-mode', mode) + + generate: () -> + @.deprecatedsCount = @.attachments.count (it) -> it.getIn(['file', 'is_deprecated']) + + if @.deprecatedsVisible + @.attachmentsVisible = @.attachments + else + @.attachmentsVisible = @.attachments.filter (it) -> !it.getIn(['file', 'is_deprecated']) + + toggleDeprecatedsVisible: () -> + @.deprecatedsVisible = !@.deprecatedsVisible + @.generate() + + addAttachment: (file) -> + return new Promise (resolve, reject) => + if @attachmentsService.validate(file) + @.uploadingAttachments.push(file) + + + promise = @attachmentsService.upload(file, @.objId, @.projectId, @.type) + promise.then (file) => + @.uploadingAttachments = @.uploadingAttachments.filter (uploading) -> + return uploading.name != file.get('name') + + attachment = Immutable.Map() + + attachment = attachment.merge({ + file: file, + editable: true, + loading: false + }) + + @.attachments = @.attachments.push(attachment) + @.generate() + @rootScope.$broadcast("attachment:create") + resolve(@.attachments) + else + reject(file) + + addAttachments: (files) -> + _.forEach files, @.addAttachment.bind(this) + + deleteAttachment: (toDeleteAttachment) -> + title = @translate.instant("ATTACHMENT.TITLE_LIGHTBOX_DELETE_ATTACHMENT") + message = @translate.instant("ATTACHMENT.MSG_LIGHTBOX_DELETE_ATTACHMENT", { + fileName: toDeleteAttachment.getIn(['file', 'name']) + }) + + return @confirm.askOnDelete(title, message) + .then (askResponse) => + onError = () => + message = @translate.instant("ATTACHMENT.ERROR_DELETE_ATTACHMENT", {errorMessage: message}) + @confirm.notify("error", null, message) + askResponse.finish(false) + + onSuccess = () => + @.attachments = @.attachments.filter (attachment) -> attachment != toDeleteAttachment + @.generate() + + askResponse.finish() + + return @attachmentsService.delete(@.type, toDeleteAttachment.getIn(['file', 'id'])).then(onSuccess, onError) + + reorderAttachment: (attachment, newIndex) -> + oldIndex = @.attachments.findIndex (it) -> it == attachment + return if oldIndex == newIndex + + attachments = @.attachments.remove(oldIndex) + attachments = attachments.splice(newIndex, 0, attachment) + attachments = attachments.map (x, i) -> x.setIn(['file', 'order'], i + 1) + + promises = attachments.map (attachment) => + patch = {order: attachment.getIn(['file', 'order'])} + + return @attachmentsService.patch(attachment.getIn(['file', 'id']), @.type, patch) + + return Promise.all(promises.toJS()).then () => + @.attachments = attachments + @.generate() + + updateAttachment: (toUpdateAttachment) -> + index = @.attachments.findIndex (attachment) -> + return attachment.getIn(['file', 'id']) == toUpdateAttachment.getIn(['file', 'id']) + oldAttachment = @.attachments.get(index) + + patch = taiga.patch(oldAttachment.get('file'), toUpdateAttachment.get('file')) + + return @attachmentsService.patch(toUpdateAttachment.getIn(['file', 'id']), @.type, patch).then () => + @.attachments = @.attachments.set(index, toUpdateAttachment) + @.generate() + +angular.module("taigaComponents").controller("AttachmentsFull", AttachmentsFullController) diff --git a/app/modules/components/attachments-full/attachments-full.controller.spec.coffee b/app/modules/components/attachments-full/attachments-full.controller.spec.coffee new file mode 100644 index 00000000..f3f528de --- /dev/null +++ b/app/modules/components/attachments-full/attachments-full.controller.spec.coffee @@ -0,0 +1,328 @@ +### +# 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: attchments.controller.spec.coffee +### + +describe "AttachmentsController", -> + $provide = null + $controller = null + mocks = {} + + _mockAttachmentsService = -> + mocks.attachmentsService = { + upload: sinon.stub() + } + + $provide.value("tgAttachmentsService", mocks.attachmentsService) + + _mockConfirm = -> + mocks.confirm = {} + + $provide.value("$tgConfirm", mocks.confirm) + + _mockTranslate = -> + mocks.translate = { + instant: sinon.stub() + } + + $provide.value("$translate", mocks.translate) + + _mockConfig = -> + mocks.config = { + get: sinon.stub() + } + + $provide.value("$tgConfig", mocks.config) + + _mockStorage = -> + mocks.storage = { + get: sinon.stub() + } + + $provide.value("$tgStorage", mocks.storage) + + _mockRootScope = -> + mocks.rootScope = {} + + $provide.value("$rootScope", mocks.rootScope) + + _mocks = -> + module (_$provide_) -> + $provide = _$provide_ + + _mockAttachmentsService() + _mockConfirm() + _mockTranslate() + _mockConfig() + _mockStorage() + _mockRootScope() + + return null + + _inject = -> + inject (_$controller_) -> + $controller = _$controller_ + + _setup = -> + _mocks() + _inject() + + beforeEach -> + module "taigaComponents" + + _setup() + + it "generate, refresh deprecated counter", () -> + attachments = Immutable.fromJS([ + { + file: { + is_deprecated: false + } + }, + { + file: { + is_deprecated: true + } + }, + { + file: { + is_deprecated: true + } + }, + { + file: { + is_deprecated: false + } + }, + { + file: { + is_deprecated: true + } + } + ]) + + ctrl = $controller("AttachmentsFull") + + ctrl.attachments = attachments + + ctrl.generate() + + expect(ctrl.deprecatedsCount).to.be.equal(3) + + it "toggle deprecated visibility", () -> + ctrl = $controller("AttachmentsFull") + + ctrl.deprecatedsVisible = false + + ctrl.generate = sinon.spy() + + ctrl.toggleDeprecatedsVisible() + + expect(ctrl.deprecatedsVisible).to.be.true + expect(ctrl.generate).to.be.calledOnce + + describe "add attachments", () -> + it "valid attachment", (done) -> + file = Immutable.fromJS({ + file: {}, + name: 'test', + size: 3000 + }) + + mocks.attachmentsService.validate = sinon.stub() + mocks.attachmentsService.validate.withArgs(file).returns(true) + + mocks.attachmentsService.upload = sinon.stub() + mocks.attachmentsService.upload.promise().resolve(file) + + mocks.rootScope.$broadcast = sinon.spy() + + ctrl = $controller("AttachmentsFull") + ctrl.attachments = Immutable.List() + + ctrl.addAttachment(file).then () -> + expect(mocks.rootScope.$broadcast).have.been.calledWith('attachment:create') + expect(ctrl.attachments.count()).to.be.equal(1) + done() + + it "invalid attachment", () -> + file = Immutable.fromJS({ + file: {}, + name: 'test', + size: 3000 + }) + + mocks.attachmentsService.validate = sinon.stub() + mocks.attachmentsService.validate.withArgs(file).returns(false) + + mocks.attachmentsService.upload = sinon.stub() + mocks.attachmentsService.upload.promise().resolve(file) + + mocks.rootScope.$broadcast = sinon.spy() + + ctrl = $controller("AttachmentsFull") + + ctrl.attachments = Immutable.List() + + ctrl.addAttachment(file).then null, () -> + expect(ctrl.attachments.count()).to.be.equal(0) + + it "add attachments", () -> + ctrl = $controller("AttachmentsFull") + + ctrl.attachments = Immutable.List() + ctrl.addAttachment = sinon.spy() + + files = [ + {}, + {}, + {} + ] + + ctrl.addAttachments(files) + + expect(ctrl.addAttachment).to.have.callCount(3) + + describe "deleteattachments", () -> + it "success attachment", (done) -> + askResponse = { + finish: sinon.spy() + } + + mocks.translate.instant.withArgs('ATTACHMENT.TITLE_LIGHTBOX_DELETE_ATTACHMENT').returns('title') + mocks.translate.instant.withArgs('ATTACHMENT.MSG_LIGHTBOX_DELETE_ATTACHMENT').returns('message') + + mocks.confirm.askOnDelete = sinon.stub() + mocks.confirm.askOnDelete.withArgs('title', 'message').promise().resolve(askResponse) + + mocks.attachmentsService.delete = sinon.stub() + mocks.attachmentsService.delete.withArgs('us', 2).promise().resolve() + + ctrl = $controller("AttachmentsFull") + + ctrl.type = 'us' + ctrl.generate = sinon.spy() + ctrl.attachments = Immutable.fromJS([ + { + file: {id: 1} + }, + { + file: {id: 2} + }, + { + file: {id: 3} + }, + { + file: {id: 4} + } + ]) + + deleteFile = ctrl.attachments.get(1) + + ctrl.deleteAttachment(deleteFile).then () -> + expect(ctrl.generate).have.been.calledOnce + expect(ctrl.attachments.size).to.be.equal(3) + expect(askResponse.finish).have.been.calledOnce + done() + + it "error attachment", (done) -> + askResponse = { + finish: sinon.spy() + } + + mocks.translate.instant.withArgs('ATTACHMENT.TITLE_LIGHTBOX_DELETE_ATTACHMENT').returns('title') + mocks.translate.instant.withArgs('ATTACHMENT.MSG_LIGHTBOX_DELETE_ATTACHMENT').returns('message') + mocks.translate.instant.withArgs('ATTACHMENT.ERROR_DELETE_ATTACHMENT').returns('error') + + mocks.confirm.askOnDelete = sinon.stub() + mocks.confirm.askOnDelete.withArgs('title', 'message').promise().resolve(askResponse) + + mocks.confirm.notify = sinon.spy() + + mocks.attachmentsService.delete = sinon.stub() + mocks.attachmentsService.delete.promise().reject() + + ctrl = $controller("AttachmentsFull") + + ctrl.type = 'us' + ctrl.generate = sinon.spy() + ctrl.attachments = Immutable.fromJS([ + { + file: {id: 1} + }, + { + file: {id: 2} + }, + { + file: {id: 3} + }, + { + file: {id: 4} + } + ]) + + deleteFile = ctrl.attachments.get(1) + + ctrl.deleteAttachment(deleteFile).then () -> + expect(ctrl.attachments.size).to.be.equal(4) + expect(askResponse.finish.withArgs(false)).have.been.calledOnce + expect(mocks.confirm.notify.withArgs('error', null, 'error')) + done() + + it "reorder attachments", (done) -> + attachments = Immutable.fromJS([ + {file: {id: 0, is_deprecated: false, order: 0}}, + {file: {id: 1, is_deprecated: true, order: 1}}, + {file: {id: 2, is_deprecated: true, order: 2}}, + {file: {id: 3, is_deprecated: false, order: 3}}, + {file: {id: 4, is_deprecated: true, order: 4}} + ]) + + mocks.attachmentsService.patch = sinon.stub() + mocks.attachmentsService.patch.promise().resolve() + + ctrl = $controller("AttachmentsFull") + + ctrl.type = 'us' + ctrl.attachments = attachments + + ctrl.reorderAttachment(attachments.get(1), 0).then () -> + expect(ctrl.attachments.get(0)).to.be.equal(attachments.get(1)) + done() + + it "update attachment", () -> + attachments = Immutable.fromJS([ + {file: {id: 0, is_deprecated: false, order: 0}}, + {file: {id: 1, is_deprecated: true, order: 1}}, + {file: {id: 2, is_deprecated: true, order: 2}}, + {file: {id: 3, is_deprecated: false, order: 3}}, + {file: {id: 4, is_deprecated: true, order: 4}} + ]) + + attachment = attachments.get(1) + attachment = attachment.setIn(['file', 'is_deprecated'], false) + + mocks.attachmentsService.patch = sinon.stub() + mocks.attachmentsService.patch.withArgs(1, 'us', {is_deprecated: false}).promise().resolve() + + ctrl = $controller("AttachmentsFull") + + ctrl.type = 'us' + ctrl.attachments = attachments + + ctrl.updateAttachment(attachment).then () -> + expect(ctrl.attachments.get(1).toJS()).to.be.eql(attachment.toJS()) diff --git a/app/modules/components/attachments-full/attachments-full.directive.coffee b/app/modules/components/attachments-full/attachments-full.directive.coffee new file mode 100644 index 00000000..5200feb5 --- /dev/null +++ b/app/modules/components/attachments-full/attachments-full.directive.coffee @@ -0,0 +1,42 @@ +### +# 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: attchments-full.directive.coffee +### + +bindOnce = @.taiga.bindOnce + +AttachmentsFullDirective = () -> + link = (scope, el, attrs, ctrl) -> + bindOnce scope, 'vm.objId', (value) -> + ctrl.loadAttachments() + + return { + scope: {}, + bindToController: { + type: "@", + objId: "=" + projectId: "=" + }, + controller: "AttachmentsFull", + controllerAs: "vm", + templateUrl: "components/attachments-full/attachments-full.html", + link: link + } + +AttachmentsFullDirective.$inject = [] + +angular.module("taigaComponents").directive("tgAttachmentsFull", AttachmentsFullDirective) diff --git a/app/modules/components/attachments-full/attachments-full.jade b/app/modules/components/attachments-full/attachments-full.jade new file mode 100644 index 00000000..2e397046 --- /dev/null +++ b/app/modules/components/attachments-full/attachments-full.jade @@ -0,0 +1,97 @@ +section.attachments(tg-attachments-drop="vm.addAttachments(files)") + .attachments-header + h3.attachments-title #[span.attachments-num {{vm.attachments.size}}] #[span.attachments-text(translate="ATTACHMENT.SECTION_NAME")] + .options + button.view-gallery( + ng-class="{'is-active': vm.mode == 'gallery'}" + ng-if="vm.attachments.size", + ng-click="vm.setMode('gallery')" + ) + include ../../../svg/gallery.svg + button.view-list( + ng-class="{'is-active': vm.mode == 'list'}" + ng-if="vm.attachments.size", + ng-click="vm.setMode('list')" + ) + include ../../../svg/list.svg + .add-attach( + tg-check-permission="modify_{{vm.type}}" + title!="{{'ATTACHMENT.ADD' | translate}}" + ) + span.size-info( + ng-if="vm.maxFileSize", + translate="ATTACHMENT.MAX_FILE_SIZE", + translate-values="{ 'maxFileSize': vm.maxFileSize}" + ) + + label.add-attachment-button(for="add-attach") + include ../../../svg/add.svg + + input( + id="add-attach", + type="file", + ng-model="files", + multiple="multiple", + tg-file-change="vm.addAttachments(files)" + ) + .attachments-empty(ng-if="!vm.attachments.size") + div {{'ATTACHMENT.DROP' | translate}} + .attachment-list.sortable(ng-if="vm.mode == 'list'") + div(tg-attachments-sortable="vm.reorderAttachment(attachment, index)") + div( + tg-repeat="attachment in vm.attachmentsVisible track by attachment.getIn(['file', 'id'])", + tg-bind-scope + ) + tg-attachment( + attachment="attachment", + on-delete="vm.deleteAttachment(attachment)", + on-update="vm.updateAttachment(attachment)", + type="vm.type" + ) + + .single-attachment(ng-repeat="file in vm.uploadingAttachments") + .attachment-name + span.icon + include ../../../svg/attachment.svg + span {{attachment.get('name')}} + .attachment-size + span {{attachment.get('size') | sizeFormat}} + + .attachment-comments + span {{file.progressMessage}} + .percentage(ng-style="{'width': file.progressPercent}") + + a.more-attachments( + href="", + title="{{'ATTACHMENT.SHOW_DEPRECATED' | translate}}", + ng-if="vm.deprecatedsCount > 0", + ng-click="vm.toggleDeprecatedsVisible()" + ) + span.text( + ng-show="!vm.deprecatedsVisible", + translate="ATTACHMENT.SHOW_DEPRECATED" + ) + span.text( + ng-show="vm.deprecatedsVisible", + translate="ATTACHMENT.HIDE_DEPRECATED" + ) + span.more-attachments-num( + translate="ATTACHMENT.COUNT_DEPRECATED", + translate-values="{counter: '{{vm.deprecatedsCount}}'}" + ) + + .attachment-gallery(ng-if="vm.mode == 'gallery'") + tg-attachment-gallery.attachment-gallery-container( + tg-repeat="attachment in vm.attachmentsVisible track by attachment.getIn(['file', 'id'])" + attachment="attachment", + on-delete="vm.deleteAttachment(attachment)", + on-update="vm.updateAttachment(attachment)", + type="vm.type" + ) + .single-attachment(ng-repeat="file in vm.uploadingAttachments") + .loading-container + img.loading-spinner( + src="/#{v}/svg/spinner-circle.svg", + alt="{{'COMMON.LOADING' | translate}}" + ) + .attachment-data {{file.progressMessage}} diff --git a/app/modules/components/attachments-simple/attachment-simple.scss b/app/modules/components/attachments-simple/attachment-simple.scss new file mode 100644 index 00000000..e69de29b diff --git a/app/modules/components/attachments-simple/attachments-simple.controller.coffee b/app/modules/components/attachments-simple/attachments-simple.controller.coffee new file mode 100644 index 00000000..cc5c2e48 --- /dev/null +++ b/app/modules/components/attachments-simple/attachments-simple.controller.coffee @@ -0,0 +1,47 @@ +### +# 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: attchments-simple.controller.coffee +### + +class AttachmentsSimpleController + @.$inject = [ + "tgAttachmentsService" + ] + + constructor: (@attachmentsService) -> + + addAttachment: (file) -> + attachment = Immutable.fromJS({ + file: file, + name: file.name, + size: file.size + }) + + if @attachmentsService.validate(file) + @.attachments = @.attachments.push(attachment) + + @.onAdd({attachment: attachment}) if @.onAdd + + addAttachments: (files) -> + _.forEach files, @.addAttachment.bind(this) + + deleteAttachment: (toDeleteAttachment) -> + @.attachments = @.attachments.filter (attachment) -> attachment != toDeleteAttachment + + @.onDelete({attachment: toDeleteAttachment}) if @.onDelete + +angular.module("taigaComponents").controller("AttachmentsSimple", AttachmentsSimpleController) diff --git a/app/modules/components/attachments-simple/attachments-simple.controller.spec.coffee b/app/modules/components/attachments-simple/attachments-simple.controller.spec.coffee new file mode 100644 index 00000000..b2f6b0f3 --- /dev/null +++ b/app/modules/components/attachments-simple/attachments-simple.controller.spec.coffee @@ -0,0 +1,96 @@ +### +# 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: attchment.controller.spec.coffee +### + +describe "AttachmentsSimple", -> + $provide = null + $controller = null + mocks = {} + scope = null + + _mockAttachmentsService = -> + mocks.attachmentsService = {} + + $provide.value("tgAttachmentsService", mocks.attachmentsService) + + _mocks = -> + module (_$provide_) -> + $provide = _$provide_ + + _mockAttachmentsService() + + return null + + _inject = -> + inject (_$controller_, $rootScope) -> + $controller = _$controller_ + scope = $rootScope.$new() + + _setup = -> + _mocks() + _inject() + + beforeEach -> + module "taigaComponents" + + _setup() + + it "add attachment", () -> + file = { + name: 'name', + size: 1000 + } + + mocks.attachmentsService.validate = sinon.stub() + mocks.attachmentsService.validate.withArgs(file).returns(true) + + ctrl = $controller("AttachmentsSimple", { + $scope: scope + }, { + attachments: Immutable.List() + }) + + ctrl.onAdd = sinon.spy() + + ctrl.addAttachment(file) + + expect(ctrl.attachments.size).to.be.equal(1) + expect(ctrl.onAdd).to.have.been.calledOnce + + it "delete attachment", () -> + attachments = Immutable.fromJS([ + {id: 1}, + {id: 2}, + {id: 3} + ]) + + ctrl = $controller("AttachmentsSimple", { + $scope: scope + }, { + attachments: attachments + }) + + ctrl.onDelete = sinon.spy() + + + attachment = attachments.get(1) + + ctrl.deleteAttachment(attachment) + + expect(ctrl.attachments.size).to.be.equal(2) + expect(ctrl.onDelete.withArgs({attachment: attachment})).to.have.been.calledOnce diff --git a/app/modules/components/attachments-simple/attachments-simple.directive.coffee b/app/modules/components/attachments-simple/attachments-simple.directive.coffee new file mode 100644 index 00000000..08bb5063 --- /dev/null +++ b/app/modules/components/attachments-simple/attachments-simple.directive.coffee @@ -0,0 +1,38 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: attchments-simple.directive.coffee +### + +AttachmentsSimpleDirective = () -> + link = (scope, el, attrs, ctrl) -> + + return { + scope: {}, + bindToController: { + attachments: "=", + onAdd: "&", + onDelete: "&" + }, + controller: "AttachmentsSimple", + controllerAs: "vm", + templateUrl: "components/attachments-simple/attachments-simple.html", + link: link + } + +AttachmentsSimpleDirective.$inject = [] + +angular.module("taigaComponents").directive("tgAttachmentsSimple", AttachmentsSimpleDirective) diff --git a/app/modules/components/attachments-simple/attachments-simple.jade b/app/modules/components/attachments-simple/attachments-simple.jade new file mode 100644 index 00000000..d40aca41 --- /dev/null +++ b/app/modules/components/attachments-simple/attachments-simple.jade @@ -0,0 +1,38 @@ +//- section.attachments(tg-attachments-drop="vm.addAttachments(files)") + +section.attachments(tg-attachments-drop="vm.addAttachments(files)") + .attachments-header + h3.attachments-title #[span.attachments-num {{vm.attachments.size}}] #[span.attachments-text(translate="ATTACHMENT.SECTION_NAME")] + .add-attach(title!="{{'ATTACHMENT.ADD' | translate}}") + span.size-info( + translate="ATTACHMENT.MAX_FILE_SIZE" + translate-values="{ 'maxFileSize': vm.maxFileSizeFormated}" + ng-if="vm.maxFileSize" + ) + label.add-attachment-button(for="add-attach") + include ../../../svg/add.svg + input( + id="add-attach" + type="file" + multiple="multiple" + ng-model="files" + tg-file-change="vm.addAttachments(files)" + ) + .attachments-empty(ng-if="!vm.attachments.size") + div {{'ATTACHMENT.DROP' | translate}} + .attachment-body.attachment-list + .single-attachment(tg-repeat="attachment in vm.attachments track by $index") + .attachment-name + span.icon + include ../../../svg/attachment.svg + span {{attachment.get('name')}} + .attachment-size + span {{attachment.get('size') | sizeFormat}} + + .attachment-settings + a.settings.attachment-delete( + href="#" + title="{{'COMMON.DELETE' | translate}}" + ng-click="vm.deleteAttachment(attachment)" + ) + include ../../../svg/remove.svg diff --git a/app/modules/components/attachments-sortable/attachments-sortable.directive.coffee b/app/modules/components/attachments-sortable/attachments-sortable.directive.coffee new file mode 100644 index 00000000..c220e9b2 --- /dev/null +++ b/app/modules/components/attachments-sortable/attachments-sortable.directive.coffee @@ -0,0 +1,52 @@ +### +# 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: attachments-sortable.directive.coffee +### + +AttachmentSortableDirective = ($parse) -> + link = (scope, el, attrs) -> + callback = $parse(attrs.tgAttachmentsSortable) + + el.sortable({ + items: "div[tg-bind-scope]" + handle: "a.settings.icon.icon-drag-v" + containment: ".attachments" + dropOnEmpty: true + helper: 'clone' + scroll: false + tolerance: "pointer" + placeholder: "sortable-placeholder single-attachment" + }) + + el.on "sortstop", (event, ui) -> + attachment = ui.item.scope().attachment + newIndex = ui.item.index() + + scope.$apply () -> + callback(scope, {attachment: attachment, index: newIndex}) + + scope.$on "$destroy", -> el.off() + + return { + link: link + } + +AttachmentSortableDirective.$inject = [ + "$parse" +] + +angular.module("taigaComponents").directive("tgAttachmentsSortable", AttachmentSortableDirective) diff --git a/app/modules/components/auto-select/auto-select.directive.coffee b/app/modules/components/auto-select/auto-select.directive.coffee new file mode 100644 index 00000000..5ac889cc --- /dev/null +++ b/app/modules/components/auto-select/auto-select.directive.coffee @@ -0,0 +1,30 @@ +### +# 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: auto-select.directive.coffee +### + +AutoSelectDirective = ($timeout) -> + return { + link: (scope, elm) -> + $timeout () -> elm[0].select() + } + +AutoSelectDirective.$inject = [ + '$timeout' +] + +angular.module("taigaComponents").directive("tgAutoSelect", AutoSelectDirective) diff --git a/app/modules/components/file-change/file-change.directive.coffee b/app/modules/components/file-change/file-change.directive.coffee new file mode 100644 index 00000000..3c94f94a --- /dev/null +++ b/app/modules/components/file-change/file-change.directive.coffee @@ -0,0 +1,39 @@ +### +# 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: file-change.directive.coffee +### + +FileChangeDirective = ($parse) -> + link = (scope, el, attrs, ctrl) -> + eventAttr = $parse(attrs.tgFileChange) + + el.on 'change', (event) -> + scope.$apply () -> eventAttr(scope, {files: event.currentTarget.files}) + + scope.$on "$destroy", -> el.off() + + return { + require: "ngModel", + restrict: "A", + link: link + } + +FileChangeDirective.$inject = [ + "$parse" +] + +angular.module("taigaComponents").directive("tgFileChange", FileChangeDirective) diff --git a/app/coffee/modules/resources/attachments.coffee b/app/modules/resources/attachments-resource.service.coffee similarity index 65% rename from app/coffee/modules/resources/attachments.coffee rename to app/modules/resources/attachments-resource.service.coffee index 3fc025fc..94cb1bb2 100644 --- a/app/coffee/modules/resources/attachments.coffee +++ b/app/modules/resources/attachments-resource.service.coffee @@ -5,6 +5,7 @@ # Copyright (C) 2014-2016 Alejandro Alonso # Copyright (C) 2014-2016 Juan Francisco Alcántara # Copyright (C) 2014-2016 Xavi Julian +# 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 @@ -19,29 +20,57 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # -# File: modules/resources/attachments.coffee +# File: attachments-resource.service.coffee ### - taiga = @.taiga sizeFormat = @.taiga.sizeFormat - -resourceProvider = ($rootScope, $config, $urls, $model, $repo, $auth, $q) -> +Resource = (urlsService, http, config, $rootScope, $q, storage) -> service = {} - service.list = (urlName, objectId, projectId) -> - params = {object_id: objectId, project: projectId} - return $repo.queryMany(urlName, params) + service.list = (type, objectId, projectId) -> + urlname = "attachments/#{type}" + + params = {object_id: objectId, project: projectId} + httpOptions = { + headers: { + "x-disable-pagination": "1" + } + } + + url = urlsService.resolve(urlname) + + return http.get(url, params, httpOptions) + .then (result) -> Immutable.fromJS(result.data) + + service.delete = (type, id) -> + urlname = "attachments/#{type}" + + url = urlsService.resolve(urlname) + "/#{id}" + + return http.delete(url) + + service.patch = (type, id, patch) -> + urlname = "attachments/#{type}" + + url = urlsService.resolve(urlname) + "/#{id}" + + return http.patch(url, patch) + + service.create = (type, projectId, objectId, file) -> + urlname = "attachments/#{type}" + + url = urlsService.resolve(urlname) - service.create = (urlName, projectId, objectId, file) -> defered = $q.defer() if file is undefined defered.reject(null) return defered.promise - maxFileSize = $config.get("maxUploadFileSize", null) + maxFileSize = config.get("maxUploadFileSize", null) + if maxFileSize and file.size > maxFileSize response = { status: 413, @@ -64,13 +93,13 @@ resourceProvider = ($rootScope, $config, $urls, $model, $repo, $auth, $q) -> status = evt.target.status try - data = JSON.parse(evt.target.responseText) + attachment = JSON.parse(evt.target.responseText) catch - data = {} + attachment = {} if status >= 200 and status < 400 - model = $model.make_model(urlName, data) - defered.resolve(model) + attachment = Immutable.fromJS(attachment) + defered.resolve(attachment) else response = { status: status, @@ -93,17 +122,26 @@ resourceProvider = ($rootScope, $config, $urls, $model, $repo, $auth, $q) -> xhr.addEventListener("load", uploadComplete, false) xhr.addEventListener("error", uploadFailed, false) - xhr.open("POST", $urls.resolve(urlName)) - xhr.setRequestHeader("Authorization", "Bearer #{$auth.getToken()}") + token = storage.get('token') + + xhr.open("POST", url) + xhr.setRequestHeader("Authorization", "Bearer #{token}") xhr.setRequestHeader('Accept', 'application/json') xhr.send(data) return defered.promise - return (instance) -> - instance.attachments = service + return () -> + return {"attachments": service} +Resource.$inject = [ + "$tgUrls", + "$tgHttp", + "$tgConfig", + "$rootScope", + "$q", + "$tgStorage" +] -module = angular.module("taigaResources") -module.factory("$tgAttachmentsResourcesProvider", ["$rootScope", "$tgConfig", "$tgUrls", "$tgModel", "$tgRepo", - "$tgAuth", "$q", resourceProvider]) +module = angular.module("taigaResources2") +module.factory("tgAttachmentsResource", Resource) diff --git a/app/modules/resources/resources.coffee b/app/modules/resources/resources.coffee index 5669d85e..b6e8a17f 100644 --- a/app/modules/resources/resources.coffee +++ b/app/modules/resources/resources.coffee @@ -24,7 +24,8 @@ services = [ "tgUserstoriesResource", "tgTasksResource", "tgIssuesResource", - "tgExternalAppsResource" + "tgExternalAppsResource", + "tgAttachmentsResource" ] Resources = ($injector) -> diff --git a/app/modules/services/attachments.service.coffee b/app/modules/services/attachments.service.coffee new file mode 100644 index 00000000..5ebfa0ce --- /dev/null +++ b/app/modules/services/attachments.service.coffee @@ -0,0 +1,87 @@ +### +# 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: attachments.service.coffee +### + +sizeFormat = @.taiga.sizeFormat + +class AttachmentsService + @.$inject = [ + "$tgConfirm", + "$tgConfig", + "$translate", + "tgResources" + ] + + constructor: (@confirm, @config, @translate, @rs) -> + @.maxFileSize = @.getMaxFileSize() + + if @.maxFileSize + @.maxFileSizeFormated = sizeFormat(@.maxFileSize) + + sizeError: (file) -> + message = @translate.instant("ATTACHMENT.ERROR_MAX_SIZE_EXCEEDED", { + fileName: file.name, + fileSize: sizeFormat(file.size), + maxFileSize: @.maxFileSizeFormated + }) + + @confirm.notify("error", message) + + validate: (file) -> + if @.maxFileSize && file.size > @.maxFileSize + @.sizeError(file) + + return false + + return true + + getMaxFileSize: () -> + return @config.get("maxUploadFileSize", null) + + list: (type, objId, projectId) -> + return @rs.attachments.list(type, objId, projectId).then (attachments) => + return attachments.sortBy (attachment) => attachment.get('order') + + delete: (type, id) -> + return @rs.attachments.delete(type, id) + + saveError: (file, data) -> + message = "" + + if file + message = @translate.instant("ATTACHMENT.ERROR_UPLOAD_ATTACHMENT", { + fileName: file.name, errorMessage: data.data._error_message + }) + + @confirm.notify("error", message) + + upload: (file, objId, projectId, type) -> + promise = @rs.attachments.create(type, projectId, objId, file) + + promise.then null, @.saveError.bind(this, file) + + return promise + + patch: (id, type, patch) -> + promise = @rs.attachments.patch(type, id, patch) + + promise.then null, @.saveError.bind(this, null) + + return promise + +angular.module("taigaCommon").service("tgAttachmentsService", AttachmentsService) diff --git a/app/modules/services/attachments.service.spec.coffee b/app/modules/services/attachments.service.spec.coffee new file mode 100644 index 00000000..d880d12d --- /dev/null +++ b/app/modules/services/attachments.service.spec.coffee @@ -0,0 +1,178 @@ +### +# 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: attachments.service.spec.coffee +### + +describe "tgAttachmentsService", -> + attachmentsService = provide = null + mocks = {} + + _mockTgConfirm = () -> + mocks.confirm = { + notify: sinon.stub() + } + + provide.value "$tgConfirm", mocks.confirm + + _mockTgConfig = () -> + mocks.config = { + get: sinon.stub() + } + + mocks.config.get.withArgs('maxUploadFileSize', null).returns(3000) + + provide.value "$tgConfig", mocks.config + + _mockRs = () -> + mocks.rs = {} + + provide.value "tgResources", mocks.rs + + _mockTranslate = () -> + mocks.translate = { + instant: sinon.stub() + } + + provide.value "$translate", mocks.translate + + + _inject = (callback) -> + inject (_tgAttachmentsService_) -> + attachmentsService = _tgAttachmentsService_ + callback() if callback + + _mocks = () -> + module ($provide) -> + provide = $provide + _mockTgConfirm() + _mockTgConfig() + _mockRs() + _mockTranslate() + + return null + + _setup = -> + _mocks() + + beforeEach -> + module "taigaCommon" + _setup() + _inject() + + it "maxFileSize formated", () -> + expect(attachmentsService.maxFileSizeFormated).to.be.equal("2.9 KB") + + it "sizeError, send notification", () -> + file = { + name: 'test', + size: 3000 + } + + mocks.translate.instant.withArgs('ATTACHMENT.ERROR_MAX_SIZE_EXCEEDED').returns('message') + + attachmentsService.sizeError(file) + + expect(mocks.confirm.notify).to.have.been.calledWith('error', 'message') + + it "invalid, validate", () -> + file = { + name: 'test', + size: 4000 + } + + result = attachmentsService.validate(file) + + expect(result).to.be.false + + it "valid, validate", () -> + file = { + name: 'test', + size: 1000 + } + + result = attachmentsService.validate(file) + + expect(result).to.be.true + + it "get max file size", () -> + result = attachmentsService.getMaxFileSize() + + expect(result).to.be.equal(3000) + + it "delete", () -> + mocks.rs.attachments = { + delete: sinon.stub() + } + + attachmentsService.delete('us', 2) + + expect(mocks.rs.attachments.delete).to.have.been.calledWith('us', 2) + + it "upload", (done) -> + file = { + id: 1 + } + + objId = 2 + projectId = 2 + type = 'us' + + mocks.rs.attachments = { + create: sinon.stub().promise() + } + + mocks.rs.attachments.create.withArgs('us', type, objId, file).resolve() + + attachmentsService.sizeError = sinon.spy() + + attachmentsService.upload(file, objId, projectId, 'us').then () -> + expect(mocks.rs.attachments.create).to.have.been.calledOnce + done() + + it "patch", (done) -> + file = { + id: 1 + } + + objId = 2 + type = 'us' + + patch = { + id: 2 + } + + mocks.rs.attachments = { + patch: sinon.stub().promise() + } + + mocks.rs.attachments.patch.withArgs('us', objId, patch).resolve() + + attachmentsService.sizeError = sinon.spy() + + attachmentsService.patch(objId, 'us', patch).then () -> + expect(mocks.rs.attachments.patch).to.have.been.calledOnce + done() + + it "error", () -> + mocks.translate.instant.withArgs("ATTACHMENT.ERROR_MAX_SIZE_EXCEEDED").returns("msg") + + attachmentsService.sizeError({ + name: 'name', + size: 123 + }) + + expect(mocks.confirm.notify).to.have.been.calledWith('error', 'msg') diff --git a/app/modules/services/lightbox-factory.service.coffee b/app/modules/services/lightbox-factory.service.coffee index 33b92464..04c9822f 100644 --- a/app/modules/services/lightbox-factory.service.coffee +++ b/app/modules/services/lightbox-factory.service.coffee @@ -21,9 +21,11 @@ class LightboxFactory @.$inject = ["$rootScope", "$compile"] constructor: (@rootScope, @compile) -> - create: (name, attrs) -> + create: (name, attrs, scopeAttrs) -> scope = @rootScope.$new() + scope = _.merge(scope, scopeAttrs) + elm = $("
") .attr(name, true) .attr("tg-bind-scope", true) diff --git a/app/modules/services/project.service.coffee b/app/modules/services/project.service.coffee index 1f576dc5..ca118352 100644 --- a/app/modules/services/project.service.coffee +++ b/app/modules/services/project.service.coffee @@ -67,6 +67,9 @@ class ProjectService @._section = null @._sectionsBreadcrumb = Immutable.List() + hasPermission: (permission) -> + return @._project.get('my_permissions').indexOf(permission) != -1 + fetchProject: () -> pslug = @.project.get('slug') diff --git a/app/modules/services/project.service.spec.coffee b/app/modules/services/project.service.spec.coffee index 18f91fa7..8b4a11f9 100644 --- a/app/modules/services/project.service.spec.coffee +++ b/app/modules/services/project.service.spec.coffee @@ -140,3 +140,20 @@ describe "tgProjectService", -> expect(projectService.activeMembers.size).to.be.equal(0); expect(projectService.section).to.be.null; expect(projectService.sectionsBreadcrumb.size).to.be.equal(0); + + it "has permissions", () -> + project = Immutable.Map({ + id: 1, + my_permissions: [ + 'test1', + 'test2' + ] + }) + + projectService._project = project + + perm1 = projectService.hasPermission('test2') + perm2 = projectService.hasPermission('test3') + + expect(perm1).to.be.true + expect(perm2).to.be.false diff --git a/app/partials/attachment/attachment-edit.jade b/app/partials/attachment/attachment-edit.jade deleted file mode 100644 index 86a57f05..00000000 --- a/app/partials/attachment/attachment-edit.jade +++ /dev/null @@ -1,19 +0,0 @@ -.attachment-name - span.icon.icon-document - a(href!="<%- url %>", title!="<%- title %>", target="_blank", download!="<%- name %>") - | <%- name %> -.attachment-size - span <%- size %> - -.editable.editable-attachment-comment - input(type="text", name="description", maxlength="140", - value!="<%- description %>", placeholder="{{'ATTACHMENT.DESCRIPTION' | translate}}") - -.editable.editable-attachment-deprecated - input(type="checkbox", name="is-deprecated", - id!="attach-<%- id %>-is-deprecated") - label(for!="attach-<%- id %>-is-deprecated", translate="{{'ATTACHMENT.DEPRECATED_FILE' | translate}}") - -.attachment-settings - a.editable-settings.icon.icon-floppy(href="", title="{{'COMMON.SAVE' | translate}}") - a.editable-settings.icon.icon-delete(href="", title="{{'COMMON.CANCEL' | translate}}") diff --git a/app/partials/attachment/attachment.jade b/app/partials/attachment/attachment.jade deleted file mode 100644 index 9e2c76da..00000000 --- a/app/partials/attachment/attachment.jade +++ /dev/null @@ -1,19 +0,0 @@ -.attachment-name - a(href!="<%- url %>", title!="<%- title %>", target="_blank", download!="<%- name %>") - span.icon.icon-documents - span <%- name %> -.attachment-size - span <%- size %> - -.attachment-comments - <% if (isDeprecated){ %> - span.deprecated-file {{'ATTACHMENT.DEPRECATED' | translate}} - <% } %> - span <%- description %> - -<% if (modifyPermission) {%> -.attachment-settings - a.settings.icon.icon-edit(href="", title="{{'COMMON.EDIT' | translate}}") - a.settings.icon.icon-delete(href="", title="{{'COMMON.DELETE' | translate}}") - a.settings.icon.icon-drag-v(href="", title="{{'COMMON.DRAG' | translate}}") -<% } %> diff --git a/app/partials/attachment/attachments.jade b/app/partials/attachment/attachments.jade deleted file mode 100644 index 25ada019..00000000 --- a/app/partials/attachment/attachments.jade +++ /dev/null @@ -1,30 +0,0 @@ -section.attachments - .attachments-header - h3.attachments-title - span.attachments-num(tg-bind-html="ctrl.attachmentsCount") - span.attachments-text(translate="ATTACHMENT.SECTION_NAME") - .add-attach(tg-check-permission!="modify_<%- type %>", title!="{{'ATTACHMENT.ADD' | translate}}") - <% if (maxFileSize){ %> - span.size-info.hidden(translate="ATTACHMENT.MAX_FILE_SIZE", translate-values!="{ 'maxFileSize': '<%- maxFileSize %>'}") - <% }; %> - label(for="add-attach", class="icon icon-plus related-tasks-buttons") - input(id="add-attach", type="file", multiple="multiple") - - .attachment-body.sortable - .single-attachment(ng-repeat="attach in ctrl.attachments|filter:ctrl.filterAttachments track by attach.id" tg-attachment="attach", tg-bind-scope) - - .single-attachment(ng-repeat="file in ctrl.uploadingAttachments") - .attachment-name - a(href="", tg-bo-title="file.name", tg-bo-bind="file.name") - .attachment-size - span.attachment-size(tg-bo-bind="file.size") - .attachment-comments - span(ng-bind="file.progressMessage") - .percentage(ng-style="{'width': file.progressPercent}") - - a.more-attachments(href="", title="{{'ATTACHMENT.SHOW_DEPRECATED' | translate}}", ng-if="ctrl.deprecatedAttachmentsCount > 0") - span.text(data-type="show", translate="ATTACHMENT.SHOW_DEPRECATED") - span.text.hidden(data-type="hide", translate="ATTACHMENT.HIDE_DEPRECATED") - span.more-attachments-num(translate="ATTACHMENT.COUNT_DEPRECATED", translate-values="{counter: '{{ctrl.deprecatedAttachmentsCount}}'}") - - div.lightbox.lightbox-block(tg-lb-attachment-preview) \ No newline at end of file diff --git a/app/partials/common/components/add-button.jade b/app/partials/common/components/add-button.jade new file mode 100644 index 00000000..a510e0a1 --- /dev/null +++ b/app/partials/common/components/add-button.jade @@ -0,0 +1,4 @@ +a.add-button( + href="" +) + include ../../../svg/add.svg diff --git a/app/partials/common/components/editable-description.jade b/app/partials/common/components/editable-description.jade index caa56bd6..6cfdeda1 100644 --- a/app/partials/common/components/editable-description.jade +++ b/app/partials/common/components/editable-description.jade @@ -1,11 +1,11 @@ +include wysiwyg.jade + .view-description section.us-content.wysiwyg(tg-bind-html="item.description_html || noDescriptionMsg") span.edit.icon.icon-edit .edit-description textarea(ng-attr-placeholder="{{'COMMON.DESCRIPTION.EMPTY' | translate}}", ng-model="item.description", tg-markitup="tg-markitup") - a.help-markdown(href="https://taiga.io/support/taiga-markdown-syntax/", target="_blank", title="{{'COMMON.WYSIWYG.MARKDOWN_HELP' | translate}}") - span.icon.icon-help - span(translate="COMMON.WYSIWYG.MARKDOWN_HELP") + +wysihelp span.save-container a.save.icon.icon-floppy(href="", title="{{'COMMON.SAVE' | translate}}") diff --git a/app/partials/common/components/wysiwyg.jade b/app/partials/common/components/wysiwyg.jade new file mode 100644 index 00000000..3d90e44a --- /dev/null +++ b/app/partials/common/components/wysiwyg.jade @@ -0,0 +1,6 @@ +mixin wysihelp + div.wysiwyg-help + span.drag-drop-help Attach files by dragging & dropping on the textarea above. + a.help-markdown(href="https://taiga.io/support/taiga-markdown-syntax/", target="_blank", title="{{'COMMON.WYSIWYG.MARKDOWN_HELP' | translate}}") + span.icon.icon-help + span(translate="COMMON.WYSIWYG.MARKDOWN_HELP") diff --git a/app/partials/common/history/history-base.jade b/app/partials/common/history/history-base.jade index 0cb8289a..5accec64 100644 --- a/app/partials/common/history/history-base.jade +++ b/app/partials/common/history/history-base.jade @@ -1,4 +1,6 @@ -section.history(tg-check-permission!="modify_<%- type %>") +include ../components/wysiwyg.jade + +section.history ul.history-tabs li a(href="#", class="active", data-section-class="history-comments") @@ -10,13 +12,12 @@ section.history(tg-check-permission!="modify_<%- type %>") span.tab-title(translate="ACTIVITY.TITLE") section.history-comments .comments-list - div(tg-toggle-comment, class="add-comment") - textarea(ng-attr-placeholder="{{'COMMENTS.TYPE_NEW_COMMENT' | translate}}", ng-model!="<%- ngmodel %>.comment", tg-markitup="tg-markitup") - <% if (mode !== "edit") { %> - a(class="help-markdown", href="https://taiga.io/support/taiga-markdown-syntax/", target="_blank", title="{{'COMMON.WYSIWYG.MARKDOWN_HELP' | translate}}") - span.icon.icon-help - span(translate="COMMON.WYSIWYG.MARKDOWN_HELP") - button(type="button", ng-disabled!="!<%- ngmodel %>.comment.length" title="{{'COMMENTS.COMMENT' | translate}}", translate="COMMENTS.COMMENT", class="button button-green save-comment") - <% } %> + div(tg-editable-wysiwyg, ng-model!="<%- ngmodel %>") + div(tg-check-permission!="modify_<%- type %>", tg-toggle-comment, class="add-comment") + textarea(ng-attr-placeholder="{{'COMMENTS.TYPE_NEW_COMMENT' | translate}}", ng-model!="<%- ngmodel %>.comment", tg-markitup="tg-markitup") + <% if (mode !== "edit") { %> + +wysihelp + button(type="button", ng-disabled!="!<%- ngmodel %>.comment.length" title="{{'COMMENTS.COMMENT' | translate}}", translate="COMMENTS.COMMENT", class="button button-green save-comment") + <% } %> section.history-activity.hidden .changes-list diff --git a/app/partials/common/lightbox/lightbox-attachment-preview.jade b/app/partials/common/lightbox/lightbox-attachment-preview.jade index 91932ed7..b1b1ebb5 100644 --- a/app/partials/common/lightbox/lightbox-attachment-preview.jade +++ b/app/partials/common/lightbox/lightbox-attachment-preview.jade @@ -2,5 +2,5 @@ a.close(href="", title="{{'COMMON.CLOSE' | translate}}") span.icon.icon-delete - a(href!="<%- url %>", title!="<%- title %>", target="_blank", download!="<%- name %>") - img(src!="<%- url %>") \ No newline at end of file + a(href="{{::file.get('url')}}", title="{{::file.get('description')}}", target="_blank", download="{{::file.get('name')}}") + img(src="{{::file.get('url')}}") \ No newline at end of file diff --git a/app/partials/common/lightbox/lightbox-blocking-message-input.jade b/app/partials/common/lightbox/lightbox-blocking-message-input.jade index 3c1c84c8..68032eee 100644 --- a/app/partials/common/lightbox/lightbox-blocking-message-input.jade +++ b/app/partials/common/lightbox/lightbox-blocking-message-input.jade @@ -1,4 +1,7 @@ fieldset.blocked-note.hidden - textarea(name="blocked_note", - ng-attr-placeholder="{{'COMMON.BLOCKED_NOTE' | translate}}", - ng-model!="<%- ngmodel %>") + input( + type="text" + name="blocked_note" + ng-attr-placeholder="{{'COMMON.BLOCKED_NOTE' | translate}}" + ng-model!="<%- ngmodel %>" + ) diff --git a/app/partials/includes/modules/lightbox-create-issue.jade b/app/partials/includes/modules/lightbox-create-issue.jade index dae01853..a94bff17 100644 --- a/app/partials/includes/modules/lightbox-create-issue.jade +++ b/app/partials/includes/modules/lightbox-create-issue.jade @@ -16,6 +16,14 @@ form fieldset div.tags-block(tg-lb-tag-line, ng-model="issue.tags") + fieldset + section + tg-attachments-simple( + attachments="attachments", + on-add="addAttachment(attachment)" + on-delete="deleteAttachment(attachment)" + ) + fieldset textarea.description(ng-attr-placeholder="{{'COMMON.FIELDS.DESCRIPTION' | translate}}", ng-model="issue.description") diff --git a/app/partials/includes/modules/lightbox-task-create-edit.jade b/app/partials/includes/modules/lightbox-task-create-edit.jade index 3c7c7ca7..900b6886 100644 --- a/app/partials/includes/modules/lightbox-task-create-edit.jade +++ b/app/partials/includes/modules/lightbox-task-create-edit.jade @@ -18,6 +18,14 @@ form fieldset div.tags-block(tg-lb-tag-line, ng-model="task.tags") + fieldset + section + tg-attachments-simple( + attachments="attachments", + on-add="addAttachment(attachment)" + on-delete="deleteAttachment(attachment)" + ) + fieldset textarea.description(ng-attr-placeholder="{{'LIGHTBOX.CREATE_EDIT_TASK.PLACEHOLDER_SHORT_DESCRIPTION' | translate}}", ng-model="task.description") diff --git a/app/partials/includes/modules/lightbox-us-create-edit.jade b/app/partials/includes/modules/lightbox-us-create-edit.jade index 98259aa3..eb4c6535 100644 --- a/app/partials/includes/modules/lightbox-us-create-edit.jade +++ b/app/partials/includes/modules/lightbox-us-create-edit.jade @@ -18,6 +18,14 @@ form fieldset textarea.description(name="description", ng-model="us.description", ng-attr-placeholder="{{'LIGHTBOX.CREATE_EDIT_US.PLACEHOLDER_DESCRIPTION' | translate}}") + fieldset + section + tg-attachments-simple( + attachments="attachments", + on-add="addAttachment(attachment)" + on-delete="deleteAttachment(attachment)" + ) + div.settings fieldset.team-requirement input(type="checkbox", name="team_requirement", ng-model="us.team_requirement", diff --git a/app/partials/includes/modules/related-tasks.jade b/app/partials/includes/modules/related-tasks.jade index 37efe89c..40c900b4 100644 --- a/app/partials/includes/modules/related-tasks.jade +++ b/app/partials/includes/modules/related-tasks.jade @@ -1,8 +1,12 @@ section.related-tasks(tg-related-tasks) - div.related-tasks-header + .related-tasks-header span.related-tasks-title(translate="COMMON.RELATED_TASKS") div(tg-related-task-create-button) - div.related-tasks-body - div.row.single-related-task(ng-repeat="task in tasks", ng-class="{closed: task.is_closed, blocked: task.is_blocked, iocaine: task.is_iocaine}", - tg-related-task-row, ng-model="task") - div.row.single-related-task.related-task-create-form(tg-related-task-create-form) + .related-tasks-body + .row.single-related-task( + ng-repeat="task in tasks" + ng-class="{closed: task.is_closed, blocked: task.is_blocked, iocaine: task.is_iocaine}" + tg-related-task-row + ng-model="task" + ) + .row.single-related-task.related-task-create-form(tg-related-task-create-form) diff --git a/app/partials/issue/issues-detail.jade b/app/partials/issue/issues-detail.jade index 397bb6fc..06e00bc0 100644 --- a/app/partials/issue/issues-detail.jade +++ b/app/partials/issue/issues-detail.jade @@ -60,7 +60,7 @@ div.wrapper( div.tags-block(tg-tag-line, ng-model="issue", required-perm="modify_issue") - section.duty-content(tg-editable-description, ng-model="issue", required-perm="modify_issue") + section.duty-content(tg-editable-description, tg-editable-wysiwyg, ng-model="issue", required-perm="modify_issue") // Custom Fields tg-custom-attributes-values( @@ -70,7 +70,12 @@ div.wrapper( required-edition-perm="modify_issue" ) - tg-attachments(ng-model="issue", type="issue") + tg-attachments-full( + obj-id="issue.id" + type="issue", + project-id="projectId" + ) + tg-history(ng-model="issue", type="issue") sidebar.menu-secondary.sidebar.ticket-data diff --git a/app/partials/issue/issues.jade b/app/partials/issue/issues.jade index cee3d14a..70f2f701 100644 --- a/app/partials/issue/issues.jade +++ b/app/partials/issue/issues.jade @@ -1,6 +1,6 @@ doctype html -div.wrapper.issues(tg-issues, ng-controller="IssuesController as ctrl", ng-init="section='issues'") +div.wrapper.issues.lightbox-generic-form(tg-issues, ng-controller="IssuesController as ctrl", ng-init="section='issues'") tg-project-menu sidebar.menu-secondary.extrabar.filters-bar(tg-issues-filters) include ../includes/modules/issues-filters diff --git a/app/partials/task/related-task-row.jade b/app/partials/task/related-task-row.jade index bbef4fd6..0a51af0d 100644 --- a/app/partials/task/related-task-row.jade +++ b/app/partials/task/related-task-row.jade @@ -1,27 +1,46 @@ -div(class="tasks") - div(class="task-name") - span(class="icon icon-iocaine") - a(tg-nav="project-tasks-detail:project=project.slug,ref=task.ref" title!="#<%- task.ref %> <%- task.subject %>" class="clickable") - span #<%- task.ref %>  +.tasks + .task-name + .icon.icon-iocaine + a.clickable( + tg-nav="project-tasks-detail:project=project.slug,ref=task.ref" + title!="#<%- task.ref %> <%- task.subject %>") + span #<%- task.ref %> span <%- task.subject %> - div(class="task-settings") + .task-settings <% if(perms.modify_task) { %> - a(href="" title="{{'COMMON.EDIT' | translate}}" class="icon icon-edit") + a.icon.icon-edit( + href="" + title="{{'COMMON.EDIT' | translate}}" + ) <% } %> <% if(perms.delete_task) { %> - a(href="" title="{{'COMMON.DELETE' | translate}}" class="icon icon-delete delete-task") + a.icon.icon-delete.delete-task( + href="" + title="{{'COMMON.DELETE' | translate}}" + ) <% } %> -div(tg-related-task-status="task" ng-model="task" class="status") - a(href="" title="{{'TASK.TITLE_SELECT_STATUS' | translate}}" class="task-status") - span(class="task-status-bind") +.status( + tg-related-task-status="task" + ng-model="task" +) + a.task-status( + href="" + title="{{'TASK.TITLE_SELECT_STATUS' | translate}}" + ) + span.task-status-bind <% if(perms.modify_task) { %> - span(class="icon icon-arrow-bottom") + span.icon.icon-arrow-bottom <% } %> -div(tg-related-task-assigned-to-inline-edition="task" class="assigned-to") - div(title="{{'COMMON.FIELDS.ASSIGNED_TO' | translate}}" class="task-assignedto <% if(perms.modify_task) { %>editable<% } %>") - figure(class="avatar") +.assigned-to( + tg-related-task-assigned-to-inline-edition="task" +) + .task-assignedto( + title="{{'COMMON.FIELDS.ASSIGNED_TO' | translate}}" + class="<% if(perms.modify_task) { %>editable<% } %>" + ) + figure.avatar <% if(perms.modify_task) { %> - span(class="icon icon-arrow-bottom") + span.icon.icon-arrow-bottom <% } %> diff --git a/app/partials/task/task-detail.jade b/app/partials/task/task-detail.jade index 1f732574..c8e7dfb6 100644 --- a/app/partials/task/task-detail.jade +++ b/app/partials/task/task-detail.jade @@ -75,7 +75,7 @@ div.wrapper( div.tags-block(tg-tag-line, ng-model="task", required-perm="modify_task") - section.duty-content(tg-editable-description, ng-model="task", required-perm="modify_task") + section.duty-content(tg-editable-description, tg-editable-wysiwyg, ng-model="task", required-perm="modify_task") // Custom Fields tg-custom-attributes-values( @@ -85,7 +85,12 @@ div.wrapper( required-edition-perm="modify_task" ) - tg-attachments(ng-model="task", type="task") + tg-attachments-full( + obj-id="task.id" + type="task", + project-id="projectId" + ) + tg-history(ng-model="task", type="task") sidebar.menu-secondary.sidebar.ticket-data diff --git a/app/partials/us/us-detail.jade b/app/partials/us/us-detail.jade index f3606df8..1b845b7a 100644 --- a/app/partials/us/us-detail.jade +++ b/app/partials/us/us-detail.jade @@ -68,7 +68,7 @@ div.wrapper( div.tags-block(tg-tag-line, ng-model="us", required-perm="modify_us") - section.duty-content(tg-editable-description, ng-model="us", required-perm="modify_us") + section.duty-content(tg-editable-description, tg-editable-wysiwyg, ng-model="us", required-perm="modify_us") // Custom Fields tg-custom-attributes-values( @@ -80,10 +80,12 @@ div.wrapper( include ../includes/modules/related-tasks - tg-attachments( - ng-model="us" - type="us" + tg-attachments-full( + obj-id="us.id" + type="us", + project-id="projectId" ) + tg-history( ng-model="us" type="us" diff --git a/app/partials/wiki/editable-wiki-content.jade b/app/partials/wiki/editable-wiki-content.jade index 0aa56352..2faf3b51 100644 --- a/app/partials/wiki/editable-wiki-content.jade +++ b/app/partials/wiki/editable-wiki-content.jade @@ -1,3 +1,5 @@ +include ../common/components/wysiwyg.jade + .view-wiki-content section.wysiwyg(tg-bind-html='wiki.html') span.edit.icon.icon-edit(title="{{'COMMON.EDIT' | translate}}", ng-if="wiki") @@ -5,11 +7,7 @@ .edit-wiki-content(style='display: none;') textarea(ng-attr-placeholder="{{'WIKI.PLACEHOLDER_PAGE' | translate}}", ng-model='wiki.content', tg-markitup='tg-markitup') - - a.help-markdown(href='https://taiga.io/support/taiga-markdown-syntax/', target='_blank', - title="{{'COMMON.WYSIWYG.MARKDOWN_HELP' | translate}}") - span.icon.icon-help - span(translate="COMMON.WYSIWYG.MARKDOWN_HELP") + +wysihelp span.action-container a.save.icon.icon-floppy(href='', title="{{'COMMON.SAVE' | translate}}") diff --git a/app/partials/wiki/wiki.jade b/app/partials/wiki/wiki.jade index ec699d01..fc363007 100644 --- a/app/partials/wiki/wiki.jade +++ b/app/partials/wiki/wiki.jade @@ -16,7 +16,12 @@ div.wrapper(ng-controller="WikiDetailController as ctrl", h2.wiki-title(ng-bind='wikiTitle') section.wiki-content(tg-editable-wiki-content, ng-model="wiki") - tg-attachments(ng-model="wiki", type="wiki_page", ng-if="wiki.id") + tg-attachments-full( + ng-if="wiki.id" + obj-id="wiki.id" + type="wiki_page", + project-id="projectId" + ) a.remove(href="", ng-click="ctrl.delete()", ng-if="wiki.id", title="{{'WIKI.REMOVE' | translate}}", tg-check-permission="delete_wiki_page") span.icon.icon-delete diff --git a/app/styles/components/editor-help.scss b/app/styles/components/editor-help.scss new file mode 100644 index 00000000..64880b9a --- /dev/null +++ b/app/styles/components/editor-help.scss @@ -0,0 +1,29 @@ +.wysiwyg-help { + background: $whitish; + display: flex; + justify-content: space-between; + margin-top: -.5rem; + padding: .25rem .5rem; +} + +.drag-drop-help { + @extend %xsmall; + color: $gray; +} + +.help-markdown, +.help-button { + @extend %xsmall; + &:hover { + span { + transition: color .2s linear; + } + .icon { + color: $primary-light; + transition: color .2s linear; + } + } + .icon { + margin-right: .2rem; + } +} diff --git a/app/styles/components/markdown-help.scss b/app/styles/components/markdown-help.scss deleted file mode 100644 index f43ae37b..00000000 --- a/app/styles/components/markdown-help.scss +++ /dev/null @@ -1,19 +0,0 @@ -a.help-markdown, -a.help-button { - @extend %small; - color: $gray-light; - &:hover { - span { - color: $grayer; - transition: color .2s linear; - } - .icon { - color: $primary-light; - transition: color .2s linear; - } - } - .icon { - color: $gray-light; - margin-right: .2rem; - } -} diff --git a/app/styles/core/forms.scss b/app/styles/core/forms.scss index ea30f641..af6aad26 100644 --- a/app/styles/core/forms.scss +++ b/app/styles/core/forms.scss @@ -21,6 +21,15 @@ textarea { transition: border .3s linear; } } +button, +button:active, +button:focus { + background: none; + border: 0; + outline: 0; + outline-style: none; + outline-width: 0; +} textarea { min-height: 10rem; resize: vertical; diff --git a/app/styles/dependencies/mixins.scss b/app/styles/dependencies/mixins.scss index acb113d4..a77f687e 100644 --- a/app/styles/dependencies/mixins.scss +++ b/app/styles/dependencies/mixins.scss @@ -141,3 +141,9 @@ } } } + +@mixin cursor-progress { + .in-progress { + cursor: progress; + } +} \ No newline at end of file diff --git a/app/styles/layout/ticket-detail.scss b/app/styles/layout/ticket-detail.scss index 3fa22c44..fcd90e4d 100644 --- a/app/styles/layout/ticket-detail.scss +++ b/app/styles/layout/ticket-detail.scss @@ -166,6 +166,7 @@ } .duty-content { + @include cursor-progress; position: relative; &:hover { .view-description { diff --git a/app/styles/layout/wiki.scss b/app/styles/layout/wiki.scss index 1285b1d9..50134ab9 100644 --- a/app/styles/layout/wiki.scss +++ b/app/styles/layout/wiki.scss @@ -24,6 +24,7 @@ } .wiki-content { + @include cursor-progress; margin-bottom: 2rem; position: relative; &.editable { diff --git a/app/styles/modules/common/attachments.scss b/app/styles/modules/common/attachments.scss deleted file mode 100644 index 48aa96a0..00000000 --- a/app/styles/modules/common/attachments.scss +++ /dev/null @@ -1,190 +0,0 @@ -.attachments { - margin-bottom: 2rem; -} - -.attachments-header { - align-content: space-between; - align-items: center; - background: $whitish; - display: flex; - justify-content: space-between; - padding: .5rem 1rem; - .attachments-title { - @extend %medium; - @extend %bold; - color: $grayer; - } - .attachments-num, - .attachments-text { - margin-right: .1rem; - } - .icon { - @extend %large; - color: $grayer; - cursor: pointer; - &:hover { - color: $primary; - transition: color .2s ease-in; - } - } -} - -.single-attachment { - @extend %small; - align-items: center; - border-bottom: 1px solid $whitish; - display: flex; - padding: .5rem 0 .5rem 1rem; - position: relative; - &:hover { - .attachment-settings { - .settings { - opacity: 1; - transition: opacity .2s ease-in; - } - } - } - &.ui-sortable-helper { - background: lighten($primary, 60%); - box-shadow: 1px 1px 10px rgba($black, .1); - transition: background .2s ease-in; - } - &.deprecated { - color: $gray-light; - .attachment-name a { - color: $gray-light; - } - } - &.sortable-placeholder { - background: $whitish; - height: 40px; - } - .attachment-name { - @extend %bold; - @include ellipsis(200px); - flex-basis: 35%; - flex-grow: 1; - padding-right: 1rem; - .icon { - margin-right: .5rem; - } - } - .attachment-size { - color: $gray-light; - flex-basis: 15%; - flex-grow: 1; - margin-right: .5rem; - } - .attachment-comments, - .editable-attachment-comment { - flex-basis: 35%; - flex-grow: 1; - span { - color: $gray; - } - } - .editable-attachment-comment { - @extend %small; - } - .attachment-settings { - flex-basis: 15%; - flex-grow: 1; - .settings, - .editable-settings { - @extend %large; - color: $gray-light; - display: block; - position: absolute; - &:hover { - color: $primary; - } - } - .settings { - opacity: 0; - top: .5rem; - } - .editable-settings { - opacity: 1; - top: 1rem; - } - .icon-edit, - .icon-floppy { - right: 3.5rem; - } - .icon-delete { - right: 2rem; - &:hover { - color: $red; - } - } - .icon-drag-v { - cursor: move; - right: 0; - } - } - .icon-delete { - @extend %large; - color: $gray-light; - &:hover { - color: $red; - } - } - .editable-attachment-deprecated { - padding-left: 1rem; - span { - color: $gray-light; - } - input { - margin-right: .2rem; - vertical-align: middle; - &:checked+span { - color: $grayer; - } - } - } - .percentage { - background: rgba($primary, .1); - bottom: 0; - height: 40px; - left: 0; - position: absolute; - top: 0; - width: 45%; - } -} - -.more-attachments { - @extend %small; - border-bottom: 1px solid $gray-light; - display: block; - padding: 1rem 0 1rem 1rem; - span { - color: $gray-light; - } - .more-attachments-num { - color: $primary; - margin-left: .5rem; - } - &:hover { - background: lighten($primary, 60%); - transition: background .2s ease-in; - } -} - -.add-attach { - cursor: pointer; - overflow: hidden; - position: relative; - input { - display: none; - } - span { - @extend %small; - color: $gray-light; - } -} - -.attachment-preview img { - max-height: 95vh; - max-width: 95vw; -} diff --git a/app/styles/modules/common/history.scss b/app/styles/modules/common/history.scss index 3c6947c3..7145097e 100644 --- a/app/styles/modules/common/history.scss +++ b/app/styles/modules/common/history.scss @@ -67,10 +67,12 @@ } } .add-comment { + @include cursor-progress; @include clearfix; &.active { .button-green { display: block; + margin-top: .5rem; } textarea { height: 6rem; @@ -89,7 +91,6 @@ textarea { background: $white; height: 5rem; - margin-bottom: .5rem; min-height: 41px; } .help-markdown { diff --git a/app/styles/modules/common/lightbox.scss b/app/styles/modules/common/lightbox.scss index 14c3c70a..936bbb68 100644 --- a/app/styles/modules/common/lightbox.scss +++ b/app/styles/modules/common/lightbox.scss @@ -18,12 +18,9 @@ } textarea { - margin-bottom: 1rem; - max-height: 9rem; - min-height: 7rem; + min-height: 4.5rem; resize: vertical; } - label { @extend %xsmall; background: $whitish; @@ -47,12 +44,12 @@ .settings { display: flex; justify-content: center; - margin-bottom: 1rem; fieldset { margin-right: .5rem; &:hover { color: $white; transition: all .2s ease-in; + transition-delay: .2s; } &:last-child { margin: 0; @@ -98,6 +95,26 @@ display: none; } } + .attachments { + margin-bottom: 0; + } + .attachment-body { + max-height: 7.5rem; + overflow-y: auto; + } + .attachment-delete { + right: .5rem; + svg { + fill: $gray-light; + height: 1.25rem; + width: 1.25rem; + } + &:hover { + svg { + fill: $red; + } + } + } } .lightbox-generic-bulk { @@ -493,7 +510,7 @@ .ticket-role-points { flex-grow: 1; flex-shrink: 1; - max-width: calc(100% * (1/5) - .2rem); + max-width: calc(100% * (1/6) - .2rem); &:first-child { margin-left: 0; } @@ -502,6 +519,6 @@ } } .points-per-role { - margin-bottom: 1rem; + margin-bottom: 0; } } diff --git a/app/styles/modules/common/related-tasks.scss b/app/styles/modules/common/related-tasks.scss index 9655cee8..7d4afcf9 100644 --- a/app/styles/modules/common/related-tasks.scss +++ b/app/styles/modules/common/related-tasks.scss @@ -9,18 +9,26 @@ background: $whitish; display: flex; justify-content: space-between; - padding: .5rem 1rem; .related-tasks-title { @extend %medium; @extend %bold; + margin-left: 1rem; } - .icon { - @extend %large; - color: $grayer; - cursor: pointer; - &:hover { - color: $primary; - transition: color .2s ease-in; + .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; } } } diff --git a/app/svg/attachment.svg b/app/svg/attachment.svg new file mode 100644 index 00000000..784be839 --- /dev/null +++ b/app/svg/attachment.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/svg/gallery.svg b/app/svg/gallery.svg new file mode 100644 index 00000000..d9f205bc --- /dev/null +++ b/app/svg/gallery.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/svg/list.svg b/app/svg/list.svg new file mode 100644 index 00000000..f2ef5c24 --- /dev/null +++ b/app/svg/list.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/svg/pattern.svg b/app/svg/pattern.svg new file mode 100644 index 00000000..beb95ba7 --- /dev/null +++ b/app/svg/pattern.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/themes/high-contrast/variables.scss b/app/themes/high-contrast/variables.scss index 54db8be6..ab6b7506 100755 --- a/app/themes/high-contrast/variables.scss +++ b/app/themes/high-contrast/variables.scss @@ -56,7 +56,7 @@ $dropdown-color: rgba(darken($primary-dark, 20%), 1); %mono {font-family: 'courier new', 'monospace';} %lightbox { - background: rgba($white, .95); + background: rgba($white, 1); } // Background images diff --git a/app/themes/material-design/variables.scss b/app/themes/material-design/variables.scss index 57ce0eb9..aa890c26 100755 --- a/app/themes/material-design/variables.scss +++ b/app/themes/material-design/variables.scss @@ -53,7 +53,7 @@ $dropdown-color: rgba(darken($primary-dark, 20%), 1); // lightbox %lightbox { - background: rgba($white, .95); + background: rgba($white, .98); } // Background images diff --git a/app/themes/taiga/variables.scss b/app/themes/taiga/variables.scss index 12202bf2..4e92c60e 100755 --- a/app/themes/taiga/variables.scss +++ b/app/themes/taiga/variables.scss @@ -62,7 +62,7 @@ $dropdown-color: rgba(darken($grayer, 20%), 1); // lightbox %lightbox { - background: rgba($white, .95); + background: rgba($white, .98); } // Background images diff --git a/e2e/helpers/common-helper.js b/e2e/helpers/common-helper.js index 6bd7ee3e..5751a866 100644 --- a/e2e/helpers/common-helper.js +++ b/e2e/helpers/common-helper.js @@ -1,6 +1,12 @@ var utils = require('../utils'); var helper = module.exports; +var chai = require('chai'); +var chaiAsPromised = require('chai-as-promised'); + +chai.use(chaiAsPromised); +var expect = chai.expect; + helper.assignToLightbox = function() { let el = $('div[tg-lb-assignedto]'); @@ -37,3 +43,23 @@ helper.assignToLightbox = function() { return obj; }; + +helper.lightboxAttachment = async function() { + let el = $('tg-attachments-simple'); + + let addAttachment = el.$('#add-attach'); + + let countAttachments = await el.$$('.single-attachment').count(); + + var fileToUpload1 = utils.common.uploadImagePath(); + var fileToUpload2 = utils.common.uploadFilePath(); + + await utils.common.uploadFile(addAttachment, fileToUpload1) + await utils.common.uploadFile(addAttachment, fileToUpload2) + + el.$$('.attachment-delete').get(0).click() + + let newCountAttachments = await el.$$('.single-attachment').count(); + + expect(countAttachments + 1).to.be.equal(newCountAttachments); +} diff --git a/e2e/helpers/detail-helper.js b/e2e/helpers/detail-helper.js index 370d6804..345febc7 100644 --- a/e2e/helpers/detail-helper.js +++ b/e2e/helpers/detail-helper.js @@ -256,14 +256,14 @@ helper.delete = function() { }; helper.attachment = function() { - let el = $('tg-attachments'); + let el = $('tg-attachments-full'); let obj = { el:el, upload: async function(filePath, name) { let addAttach = el.$('#add-attach'); - let countAttachments = await $$('div[tg-attachment]').count(); + let countAttachments = await $$('tg-attachment').count(); let toggleInput = function() { $('#add-attach').toggle(); @@ -274,37 +274,37 @@ helper.attachment = function() { await browser.waitForAngular(); await browser.wait(async () => { - let newCountAttachments = await $$('div[tg-attachment]').count(); + let newCountAttachments = await $$('tg-attachment').count(); return newCountAttachments == countAttachments + 1; }, 5000); - await el.$$('div[tg-attachment] .editable-attachment-comment input').last().sendKeys(name); + await el.$$('tg-attachment .editable-attachment-comment input').last().sendKeys(name); await browser.actions().sendKeys(protractor.Key.ENTER).perform(); await browser.executeScript(toggleInput); await browser.waitForAngular(); }, renameLastAttchment: async function (name) { - await browser.actions().mouseMove(el.$$('div[tg-attachment]').last()).perform(); - await el.$$('div[tg-attachment] .attachment-settings .icon-edit').last().click(); - await el.$$('div[tg-attachment] .editable-attachment-comment input').last().sendKeys(name); + await browser.actions().mouseMove(el.$$('tg-attachment').last()).perform(); + await el.$$('tg-attachment .attachment-settings .icon-edit').last().click(); + await el.$$('tg-attachment .editable-attachment-comment input').last().sendKeys(name); await browser.actions().sendKeys(protractor.Key.ENTER).perform(); return browser.waitForAngular(); }, getFirstAttachmentName: async function () { - let name = await el.$$('div[tg-attachment] .attachment-comments').first().getText(); + let name = await el.$$('tg-attachment .attachment-comments').first().getText(); return name; }, getLastAttachmentName: async function () { - let name = await el.$$('div[tg-attachment] .attachment-comments').last().getText(); + let name = await el.$$('tg-attachment .attachment-comments').last().getText(); return name; }, countAttachments: async function(){ - return await el.$$('div[tg-attachment]').count(); + return await el.$$('tg-attachment').count(); }, countDeprecatedAttachments: async function(){ @@ -321,10 +321,10 @@ helper.attachment = function() { }, deprecateLastAttachment: async function() { - await browser.actions().mouseMove(el.$$('div[tg-attachment]').last()).perform(); - await el.$$('div[tg-attachment] .attachment-settings .icon-edit').last().click(); - await el.$$('div[tg-attachment] .editable-attachment-deprecated input').last().click(); - await el.$$('div[tg-attachment] .attachment-settings .editable-settings.icon-floppy').last().click(); + await browser.actions().mouseMove(el.$$('tg-attachment').last()).perform(); + await el.$$('tg-attachment .attachment-settings .icon-edit').last().click(); + await el.$$('tg-attachment .editable-attachment-deprecated input').last().click(); + await el.$$('tg-attachment .attachment-settings .editable-settings.icon-floppy').last().click(); await browser.waitForAngular(); }, @@ -333,7 +333,7 @@ helper.attachment = function() { }, deleteLastAttachment: async function() { - let attachment = await $$('div[tg-attachment]').last(); + let attachment = await $$('tg-attachment').last(); await browser.actions().mouseMove(attachment).perform(); @@ -359,11 +359,23 @@ helper.attachment = function() { }, dragLastAttchmentToFirstPosition: async function() { - await browser.actions().mouseMove(el.$$('div[tg-attachment]').last()).perform(); - let lastDraggableAttachment = el.$$('div[tg-attachment] .attachment-settings .icon-drag-v').last(); - let destination = el.$$('div[tg-attachment] .attachment-settings .icon-drag-v').first(); + await browser.actions().mouseMove(el.$$('tg-attachment').last()).perform(); + let lastDraggableAttachment = el.$$('tg-attachment .attachment-settings .icon-drag-v').last(); + let destination = el.$$('tg-attachment .attachment-settings .icon-drag-v').first(); await utils.common.drag(lastDraggableAttachment, destination); - } + }, + + galleryImages: function() { + return $$('tg-attachment-gallery'); + }, + + gallery: function() { + $('.view-gallery').click(); + }, + + list: function() { + $('.view-list').click(); + }, }; return obj; diff --git a/e2e/shared/detail.js b/e2e/shared/detail.js index 1bfeece9..5f158184 100644 --- a/e2e/shared/detail.js +++ b/e2e/shared/detail.js @@ -249,6 +249,18 @@ shared.attachmentTesting = async function() { newAttachmentsLength = await attachmentHelper.countAttachments(); expect(newAttachmentsLength).to.be.equal(attachmentsLength + deprecatedAttachmentsLength); + // Gallery + attachmentHelper.gallery(); + + let countImages = await attachmentHelper.galleryImages().count(); + + commonUtil.takeScreenshot('attachments', 'gallery'); + + expect(countImages).to.be.above(0); + + attachmentHelper.list(); + + // Deleting attachmentsLength = await attachmentHelper.countAttachments(); await attachmentHelper.deleteLastAttachment(); diff --git a/e2e/suites/backlog.e2e.js b/e2e/suites/backlog.e2e.js index 173d506d..6efb7f52 100644 --- a/e2e/suites/backlog.e2e.js +++ b/e2e/suites/backlog.e2e.js @@ -1,5 +1,6 @@ var utils = require('../utils'); var backlogHelper = require('../helpers').backlog; +var commonHelper = require('../helpers').common; var chai = require('chai'); var chaiAsPromised = require('chai-as-promised'); @@ -57,9 +58,12 @@ describe('backlog', function() { //settings createUSLightbox.settings(0).click(); - await utils.common.waitTransitionTime(createUSLightbox.settings(0)); + }); + it('upload attachments', commonHelper.lightboxAttachment); + + it('screenshots', function() { utils.common.takeScreenshot('backlog', 'create-us-filled'); }); @@ -150,6 +154,8 @@ describe('backlog', function() { editUSLightbox.settings(1).click(); }); + it('upload attachments', commonHelper.lightboxAttachment); + it('send form', async function() { editUSLightbox.submit(); diff --git a/e2e/suites/issues/issues.e2e.js b/e2e/suites/issues/issues.e2e.js index 199e1b1f..fa00f94c 100644 --- a/e2e/suites/issues/issues.e2e.js +++ b/e2e/suites/issues/issues.e2e.js @@ -41,7 +41,11 @@ describe('issues list', function() { await createIssueLightbox.tags().sendKeys('bbb'); browser.actions().sendKeys(protractor.Key.ENTER).perform(); + }); + it('upload attachments', commonHelper.lightboxAttachment); + + it('screenshots', function() { utils.common.takeScreenshot('issues', 'create-issue-filled'); }); diff --git a/e2e/suites/kanban.e2e.js b/e2e/suites/kanban.e2e.js index 2c7caf3d..b85a702c 100644 --- a/e2e/suites/kanban.e2e.js +++ b/e2e/suites/kanban.e2e.js @@ -67,6 +67,12 @@ describe('kanban', function() { createUSLightbox.settings(1).click(); }); + it('upload attachments', commonHelper.lightboxAttachment); + + it('screenshots', function() { + utils.common.takeScreenshot('kanban', 'create-us-filled'); + }) + it('send form', async function() { createUSLightbox.submit(); @@ -134,6 +140,8 @@ describe('kanban', function() { createUSLightbox.settings(1).click(); }); + it('upload attachments', commonHelper.lightboxAttachment); + it('send form', async function() { createUSLightbox.submit(); diff --git a/gulpfile.js b/gulpfile.js index bb1848e8..61db5d31 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -438,7 +438,7 @@ gulp.task("jslibs-deploy", function() { .pipe(gulp.dest(paths.distVersion + "js/")); }); -gulp.task("app-watch", ["coffee-lint", "coffee", "conf", "locales", "app-loader"]); +gulp.task("app-watch", ["coffee", "conf", "locales", "app-loader"]); gulp.task("app-deploy", ["coffee", "conf", "locales", "app-loader"], function() { return gulp.src(paths.distVersion + "js/app.js")