diff --git a/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.test.ts b/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.test.ts index 4ba1b465fc..63cc94fa23 100644 --- a/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.test.ts +++ b/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.test.ts @@ -27,7 +27,6 @@ //++ import {opApiModule} from '../../../../angular-modules'; -import {WorkPackageResourceInterface} from './work-package-resource.service'; import {WorkPackageCacheService} from '../../../work-packages/work-package-cache.service'; import IHttpBackendService = angular.IHttpBackendService; import SinonStub = Sinon.SinonStub; @@ -49,6 +48,7 @@ describe('WorkPackageResource service', () => { var workPackage: any; const createWorkPackage = () => { + source = source || {}; workPackage = new WorkPackageResource(source); }; @@ -363,4 +363,81 @@ describe('WorkPackageResource service', () => { }); }); }); + + describe('when using removeAttachment', () => { + var file; + var attachment; + + beforeEach(() => { + file = {}; + attachment = { + $isHal: true, + 'delete': sinon.stub() + }; + + createWorkPackage(); + workPackage.attachments = {elements: [attachment]}; + workPackage.pendingAttachments.push(file); + }); + + describe('when the attachment is a regular file', () => { + beforeEach(() => { + workPackage.removeAttachment(file); + }); + + it('should be removed from the pending attachments', () => { + expect(workPackage.pendingAttachments).to.have.length(0); + }); + }); + + describe('when the attachment is an attachment resource', () => { + var deletion; + + beforeEach(() => { + deletion = $q.defer(); + attachment.delete.returns(deletion.promise); + sinon.stub(workPackage, 'updateAttachments'); + + workPackage.removeAttachment(attachment); + }); + + it('should call its delete method', () => { + expect(attachment.delete.calledOnce).to.be.true; + }); + + describe('when the deletion gets resolved', () => { + beforeEach(() => { + deletion.resolve(); + $rootScope.$apply(); + }); + + it('should call updateAttachments()', () => { + expect(workPackage.updateAttachments.calledOnce).to.be.true; + }); + }); + + describe('when an error occurs', () => { + var error; + + beforeEach(() => { + error = {foo: 'bar'}; + sinon.stub(wpNotificationsService, 'handleErrorResponse'); + deletion.reject(error); + $rootScope.$apply(); + }); + + it('should call the handleErrorResponse notification', () => { + const calledWithErrorAndWorkPackage = wpNotificationsService + .handleErrorResponse + .calledWith(error, workPackage); + + expect(calledWithErrorAndWorkPackage).to.be.true; + }); + + it('should not remove the attachment from the elements array', () => { + expect(workPackage.attachments.elements).to.have.length(1); + }); + }); + }); + }); }); diff --git a/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts b/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts index 20d3b0738c..055f9613e3 100644 --- a/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts +++ b/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts @@ -198,6 +198,29 @@ export class WorkPackageResource extends HalResource { ); } + /** + * Remove the given attachment either from the pending attachments or from + * the attachment collection, if it is a resource. + * + * Removing it from the elements array assures that the view gets updated immediately. + * If an error occurs, the user gets notified and the attachment is pushed to the elements. + */ + public removeAttachment(attachment) { + if (attachment.$isHal) { + attachment.delete() + .then(() => { + this.updateAttachments(); + }) + .catch(error => { + wpNotificationsService.handleErrorResponse(error, this); + this.attachments.elements.push(attachment); + }); + } + + _.pull(this.attachments.elements, attachment); + _.pull(this.pendingAttachments, attachment); + } + /** * Upload the pending attachments if the work package exists. * Do nothing, if the work package is being created. diff --git a/frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.html b/frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.html index 49e488f02a..41409bed9e 100644 --- a/frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.html +++ b/frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.html @@ -103,7 +103,7 @@
+ ng-if="!$ctrl.hideEmptyFields || $ctrl.workPackage.attachments.elements.length">

@@ -112,8 +112,7 @@

- - +
diff --git a/frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.ts b/frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.ts index 781df3d636..892a0b85a4 100644 --- a/frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.ts +++ b/frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.ts @@ -41,7 +41,6 @@ export class WorkPackageSingleViewController { public singleViewWp; public groupedFields: any[] = []; public hideEmptyFields: boolean = true; - public attachments: any[] = []; public text: any; public scope: any; @@ -72,10 +71,6 @@ export class WorkPackageSingleViewController { $scope.$on('workPackageUpdatedInEditor', () => { this.wpNotificationsService.showSave(this.workPackage); }); - - if (this.workPackage && this.workPackage.attachments) { - this.attachments = this.workPackage.attachments.elements; - } } public shouldHideGroup(group) { diff --git a/frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list-item.html b/frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list-item.html new file mode 100644 index 0000000000..e13d1bad6f --- /dev/null +++ b/frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list-item.html @@ -0,0 +1,31 @@ + + + + + + + + {{ ::attachment.fileName || attachment.name }} + + + + + + + + + diff --git a/frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list.directive.html b/frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list.directive.html new file mode 100644 index 0000000000..ef77a90d15 --- /dev/null +++ b/frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list.directive.html @@ -0,0 +1,16 @@ +
+
+ + + + +
+
diff --git a/frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list.directive.test.ts b/frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list.directive.test.ts new file mode 100644 index 0000000000..a6af0fc688 --- /dev/null +++ b/frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list.directive.test.ts @@ -0,0 +1,238 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ + +import {wpDirectivesModule, opTemplatesModule} from '../../../angular-modules'; +import {WorkPackageAttachmentListController} from './wp-attachment-list.directive'; +import IRootScopeService = angular.IRootScopeService; +import ICompileService = angular.ICompileService; +import IScope = angular.IScope; +import IAugmentedJQuery = angular.IAugmentedJQuery; +import IDirective = angular.IDirective; + +describe('wpAttachmentList directive', () => { + var scope: any; + var element: IAugmentedJQuery; + var controller: WorkPackageAttachmentListController; + var workPackage: any; + var compile; + + var template; + + beforeEach(angular.mock.module( + wpDirectivesModule.name, + opTemplatesModule.name, + + $compileProvider => { + // Mock ngClick, because triggerHandler doesn't work on the delete button for some reason. + $compileProvider.directive('ngClick', (): IDirective => ({ + restrict: 'A', + priority: 100, + scope: { + ngClick: '&', + }, + controller: angular.noop, + controllerAs: '$ctrl', + bindToController: true + })); + } + )); + beforeEach(angular.mock.inject(function ($rootScope: IRootScopeService, + $compile: ICompileService, + I18n) { + const html = ''; + scope = $rootScope.$new(); + workPackage = {}; + scope.workPackage = workPackage; + + const t = sinon.stub(I18n, 't'); + t + .withArgs('js.text_attachment_destroy_confirmation') + .returns('confirm destruction'); + + t + .withArgs('js.label_remove_file') + .returns('something fileName'); + + compile = () => { + element = $compile(html)(scope); + scope.$apply(); + controller = element.controller('wpAttachmentList'); + + const root = element.find('.work-package--attachments--files'); + const wrapper = root.children().first(); + const listItem = root.find('.inplace-edit--read'); + const fileName = listItem.find('.work-package--attachments--filename'); + const triggerLink = listItem.find('.inplace-editing--trigger-link'); + const deleteIcon = listItem.first().find('.inplace-edit--icon-wrapper').first(); + + template = {root, wrapper, listItem, fileName, triggerLink, deleteIcon}; + }; + + compile(); + })); + + afterEach(angular.mock.inject(I18n => I18n.t.restore())); + + it('should not be empty', () => { + expect(element.html()).to.be.ok; + }); + + it('should show no files', () => { + expect(template.wrapper.children()).to.have.length(0); + }); + + describe('when the work package has attachments', () => { + var attachment; + var attachments; + + beforeEach(angular.mock.inject($q => { + attachment = { + name: 'name', + fileName: 'fileName' + }; + attachments = [attachment, attachment]; + + workPackage.attachments = { + elements: attachments, + $load: sinon.stub() + }; + workPackage.pendingAttachments = attachments; + workPackage.removeAttachment = sinon.stub(); + + workPackage.attachments.$load.returns($q.when(workPackage.attachments)); + + compile(); + })); + + it('should be rendered', () => { + expect(template.root).to.have.length(1); + }); + + it('should load the attachments', () => { + expect(workPackage.attachments.$load.calledOnce).to.be.true; + }); + + it('should show the existing and pending attachments', () => { + expect(template.listItem).to.have.length(4); + }); + + it('should have an element that contains the file name', () => { + expect(template.fileName.text()).to.contain(attachment.fileName); + }); + + it('should have a link that points nowhere', () => { + expect(template.fileName.attr('href')).to.equal('#'); + }); + + it('should have a delete icon with a title that contains the file name', () => { + const icon = template.deleteIcon.find('[icon-title]'); + expect(icon.attr('icon-title')).to.contain(attachment.fileName); + }); + + it('should have a confirm-popup attribute with the destroyConfirmation text value', () => { + expect(template.deleteIcon.attr('confirm-popup')).to.equal('confirm destruction'); + }); + + describe('when using the delete button', () => { + beforeEach(() => { + template.deleteIcon.controller('ngClick').ngClick(); + }); + + it('should call the removeAttachment method of the work package', () => { + expect(workPackage.removeAttachment.called).to.be.true; + }); + }); + + describe('when the attachment has a download location', () => { + beforeEach(() => { + attachment.downloadLocation = {href: 'download'}; + compile(); + }); + + it('should link to that location', () => { + expect(template.fileName.attr('href')).to.equal(attachment.downloadLocation.href); + }); + }); + + describe('when the attachment has no file name', () => { + beforeEach(() => { + attachment.fileName = ''; + compile(); + }); + + it('should contain an element that contains the attachment name', () => { + expect(template.fileName.text()).to.contain(attachment.name); + }); + }); + + describe('when focusing an element', () => { + var focusElement; + + const testFocus = prepare => { + beforeEach(() => { + prepare(); + focusElement.triggerHandler('focus'); + }); + + it('should set the `-focus` class for that attachment', () => { + expect(template.triggerLink.hasClass('-focus')).to.be.true; + }); + + it('should be focused', () => { + expect(controller.isFocused(attachment)).to.be.true; + }); + + describe('when setting the focus somewhere else', () => { + beforeEach(() => { + focusElement.triggerHandler('blur'); + }); + + it('should unset the `-focus` class for that attachment', () => { + expect(template.triggerLink.hasClass('-focus')).to.be.false; + }); + + it('should not be focused', () => { + expect(controller.isFocused(attachment)).to.be.false; + }); + }); + }; + + describe('when focusing the file name element', () => { + testFocus(() => { + focusElement = template.fileName; + }); + }); + + describe('when focusing the delete icon', () => { + testFocus(() => { + focusElement = template.deleteIcon; + }); + }); + }); + }); +}); diff --git a/frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list.directive.ts b/frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list.directive.ts new file mode 100644 index 0000000000..da0e9d036c --- /dev/null +++ b/frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list.directive.ts @@ -0,0 +1,93 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ + +import {wpDirectivesModule} from '../../../angular-modules'; +import {WorkPackageResourceInterface} from '../../api/api-v3/hal-resources/work-package-resource.service'; +import {UploadFile} from '../../api/op-file-upload/op-file-upload.service'; +import {HalResource} from '../../api/api-v3/hal-resources/hal-resource.service'; + +export class WorkPackageAttachmentListController { + public workPackage: WorkPackageResourceInterface; + public text: any = {}; + + public itemTemplateUrl = + '/components/wp-attachments/wp-attachment-list/wp-attachment-list-item.html'; + + private focusedAttachment: any = null; + + constructor(protected wpNotificationsService, I18n) { + this.text = { + destroyConfirmation: I18n.t('js.text_attachment_destroy_confirmation'), + removeFile: arg => I18n.t('js.label_remove_file', arg) + }; + + if (this.workPackage.attachments) { + this.workPackage.attachments.$load().then(attachments => { + this.workPackage.attachments.elements = attachments.elements; + }); + } + } + + /** + * Focus an attachment. + */ + public focus(attachment) { + this.focusedAttachment = attachment; + } + + /** + * Reset the focus. + */ + public undoFocus() { + this.focus(null); + } + + /** + * Return whether an attachment is focused. + */ + public isFocused(attachment): boolean { + return this.focusedAttachment === attachment; + } +} + +function wpAttachmentListDirective() { + return { + restrict: 'E', + templateUrl: '/components/wp-attachments/wp-attachment-list/wp-attachment-list.directive.html', + + scope: { + workPackage: '=' + }, + + controller: WorkPackageAttachmentListController, + controllerAs: '$ctrl', + bindToController: true + }; +} + +wpDirectivesModule.directive('wpAttachmentList', wpAttachmentListDirective); diff --git a/frontend/app/components/wp-attachments/wp-attachments.directive.html b/frontend/app/components/wp-attachments/wp-attachments.directive.html deleted file mode 100644 index b7ac5edbe4..0000000000 --- a/frontend/app/components/wp-attachments/wp-attachments.directive.html +++ /dev/null @@ -1,42 +0,0 @@ -
-
- - - - - - - - {{::attachment.fileName || attachment.name}} - - - - - - - - - - - -
-
diff --git a/frontend/app/components/wp-attachments/wp-attachments.directive.test.ts b/frontend/app/components/wp-attachments/wp-attachments.directive.test.ts deleted file mode 100644 index 886e358ca0..0000000000 --- a/frontend/app/components/wp-attachments/wp-attachments.directive.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -//-- copyright -// OpenProject is a project management system. -// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// 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 General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See doc/COPYRIGHT.rdoc for more details. -//++ - -import {WorkPackageAttachmentsController} from './wp-attachments.directive'; -import { - openprojectModule, wpDirectivesModule, opTemplatesModule, - wpServicesModule, opConfigModule -} from '../../angular-modules'; -import IQService = angular.IQService; - -describe('wpAttachments directive', () => { - var $q:IQService; - var files; - var controller:WorkPackageAttachmentsController; - var workPackage = {}; - - beforeEach(angular.mock.module( - openprojectModule.name, - wpDirectivesModule.name, - opTemplatesModule.name - )); - - var loadPromise; - var apiPromise; - - beforeEach(angular.mock.inject(function ($rootScope, $compile, $httpBackend, _$q_) { - $q = _$q_; - - files = [{type: 'directory'}, {type: 'file'}]; - apiPromise = $q.when(''); - loadPromise = $q.when([]); - - // Skip the work package cache update - $httpBackend.expectGET('/api/v3/work_packages/1234').respond(200, {}); - - const element = angular.element(''); - const scope = $rootScope.$new(); - - scope.workPackage = workPackage; - - $compile(element)(scope); - scope.$digest(); - element.isolateScope(); - - controller = element.controller('wpAttachments'); - })); -}); diff --git a/frontend/app/components/wp-attachments/wp-attachments.directive.ts b/frontend/app/components/wp-attachments/wp-attachments.directive.ts deleted file mode 100644 index 5549f8cfec..0000000000 --- a/frontend/app/components/wp-attachments/wp-attachments.directive.ts +++ /dev/null @@ -1,141 +0,0 @@ -//-- copyright -// OpenProject is a project management system. -// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// 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 General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See doc/COPYRIGHT.rdoc for more details. -//++ - -import {wpDirectivesModule} from '../../angular-modules'; -import {WorkPackageNotificationService} from '../wp-edit/wp-notification.service'; -import {scopedObservable} from '../../helpers/angular-rx-utils'; -import {WorkPackageCacheService} from '../work-packages/work-package-cache.service'; -import {WorkPackageResourceInterface} from '../api/api-v3/hal-resources/work-package-resource.service'; -import {CollectionResourceInterface} from '../api/api-v3/hal-resources/collection-resource.service'; - -export class WorkPackageAttachmentsController { - public workPackage: WorkPackageResourceInterface; - public text: any; - public attachments: any[] = []; - public size: any; - public loading: boolean = false; - - private currentlyFocusing; - - constructor(protected $scope: any, - protected wpCacheService: WorkPackageCacheService, - protected wpNotificationsService: WorkPackageNotificationService, - protected I18n: op.I18n) { - - this.text = { - destroyConfirmation: I18n.t('js.text_attachment_destroy_confirmation'), - removeFile: arg => I18n.t('js.label_remove_file', arg) - }; - - if (this.workPackage && this.workPackage.attachments) { - this.loadAttachments(false); - } - - $scope.$on('work_packages.attachment.add', (evt, file) => { - this.attachments.push(file); - }); - - if (this.workPackage.isNew) { - this.registerCreateObserver(); - } - else { - this.registerEditObserver(); - } - } - - private registerEditObserver() { - scopedObservable(this.$scope, this.wpCacheService.loadWorkPackage( this.workPackage.id)) - .subscribe((wp: WorkPackageResourceInterface) => { - this.workPackage = wp; - this.loadAttachments(true); - }); - } - - private registerCreateObserver() { - scopedObservable(this.$scope, this.wpCacheService.onNewWorkPackage()) - .subscribe((wp: WorkPackageResourceInterface) => { - wp.uploadAttachments(this.attachments).then(() => { - // Reload the work package after attachments are uploaded to - // provide the correct links, in e.g., the description - this.wpCacheService.loadWorkPackage( wp.id, true); - }); - }); - } - - public loadAttachments(refresh: boolean = true): ng.IPromise { - this.loading = true; - - return this.workPackage.attachments.$load(refresh) - .then((collection: CollectionResourceInterface) => { - this.attachments.length = 0; - this.attachments.push(...collection.elements); - }) - .finally(() => { - this.loading = false; - }); - } - - public remove(file): void { - if (!this.workPackage.isNew && file._type === 'Attachment') { - file - .delete() - .then(() => { - this.workPackage.updateAttachments(); - }) - .catch(error => { - this.wpNotificationsService.handleErrorResponse(error, this.workPackage) - }); - } - - _.pull(this.attachments, file); - } - - public focus(attachment: any): void { - this.currentlyFocusing = attachment; - } - - public focusing(attachment: any): boolean { - return this.currentlyFocusing === attachment; - } -} - -function wpAttachmentsDirective(): ng.IDirective { - return { - restrict: 'E', - templateUrl: '/components/wp-attachments/wp-attachments.directive.html', - scope: { - workPackage: '=' - }, - - bindToController: true, - controller: WorkPackageAttachmentsController, - controllerAs: '$ctrl' - }; -} - -wpDirectivesModule.directive('wpAttachments', wpAttachmentsDirective);