Implement wpAttachmentList directive

Replace the wpAttachments directive with the two newer ones.
Also add a new method to the WP resource.
pull/4783/head
Alex Dik 8 years ago
parent fdebc281bd
commit dfe65309e5
  1. 79
      frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.test.ts
  2. 23
      frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts
  3. 5
      frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.html
  4. 5
      frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.ts
  5. 31
      frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list-item.html
  6. 16
      frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list.directive.html
  7. 238
      frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list.directive.test.ts
  8. 93
      frontend/app/components/wp-attachments/wp-attachment-list/wp-attachment-list.directive.ts
  9. 42
      frontend/app/components/wp-attachments/wp-attachments.directive.html
  10. 72
      frontend/app/components/wp-attachments/wp-attachments.directive.test.ts
  11. 141
      frontend/app/components/wp-attachments/wp-attachments.directive.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);
});
});
});
});
});

@ -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.

@ -103,7 +103,7 @@
<div class="work-packages--attachments attributes-group">
<div
class="work-packages--atachments-container"
ng-show="!$ctrl.hideEmptyFields || $ctrl.attachments.length > 0">
ng-if="!$ctrl.hideEmptyFields || $ctrl.workPackage.attachments.elements.length">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text">
@ -112,8 +112,7 @@
</div>
</div>
<wp-attachments work-package="$ctrl.workPackage"></wp-attachments>
<wp-attachment-list work-package="$ctrl.workPackage"></wp-attachment-list>
<wp-attachments-upload work-package="$ctrl.workPackage"></wp-attachments-upload>
</div>
</div>

@ -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) {

@ -0,0 +1,31 @@
<span class="inplace-editing--trigger-container">
<span class="inplace-editing--trigger-link"
ng-class="{'-focus': $ctrl.isFocused(attachment)}">
<span class="inplace-editing--container">
<span class="inplace-edit--read-value">
<i class="icon-attachment"></i>
<a
class="work-package--attachments--filename"
ng-href="{{ ::attachment.downloadLocation.href || '#' }}"
download
ng-focus="$ctrl.focus(attachment)"
ng-blur="$ctrl.undoFocus()">
{{ ::attachment.fileName || attachment.name }}
</a>
</span>
<a
href="#"
class="inplace-edit--icon-wrapper"
ng-focus="$ctrl.focus(attachment)"
ng-blur="$ctrl.undoFocus()"
ng-click="$ctrl.workPackage.removeAttachment(attachment)"
confirm-popup="{{ ::$ctrl.text.destroyConfirmation }}">
<icon-wrapper
icon-name="delete"
icon-title="{{ ::$ctrl.text.removeFile({fileName: attachment.fileName}) }}">
</icon-wrapper>
</a>
</span>
</span>
</span>

@ -0,0 +1,16 @@
<div class="work-package--attachments--files">
<div class="work-package--details--long-field">
<span
class="inplace-edit--read"
ng-include="::$ctrl.itemTemplateUrl"
ng-if="$ctrl.workPackage.pendingAttachments"
ng-repeat="attachment in $ctrl.workPackage.pendingAttachments track by attachment.name+$index">
</span>
<span
class="inplace-edit--read"
ng-include="::$ctrl.itemTemplateUrl"
ng-if="$ctrl.workPackage.attachments.elements"
ng-repeat="attachment in $ctrl.workPackage.attachments.elements track by attachment.name+$index">
</span>
</div>
</div>

@ -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 = '<wp-attachment-list work-package="workPackage"></wp-attachment-list>';
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;
});
});
});
});
});

@ -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);

@ -1,42 +0,0 @@
<div
class="work-package--attachments--files"
ng-show="$ctrl.attachments.length > 0">
<div class="work-package--details--long-field">
<span
class="inplace-edit--read"
ng-repeat="attachment in $ctrl.attachments track by attachment.name+$index">
<span class="inplace-editing--trigger-container">
<span
class="inplace-editing--trigger-link"
ng-class="{'-focus': $ctrl.focusing(attachment)}">
<span class="inplace-editing--container">
<span class="inplace-edit--read-value">
<i class="icon-attachment"></i>
<a
class="work-package--attachments--filename"
ng-href="{{attachment.downloadLocation.href || '#'}}"
download
ng-focus="$ctrl.focus(attachment)"
ng-blur="$ctrl.focus(null)">
{{::attachment.fileName || attachment.name}}
</a>
</span>
<a
href=''
class="inplace-edit--icon-wrapper"
ng-focus="$ctrl.focus(attachment)"
ng-blur="$ctrl.focus(null)"
ng-click="$ctrl.remove(attachment)"
confirm-popup="{{ ::$ctrl.destroyConfirmation }}">
<icon-wrapper
icon-name="delete"
icon-title="{{ ::$ctrl.text.removeFile({fileName: attachment.fileName}) }}">
</icon-wrapper>
</a>
</span>
</span>
</span>
</span>
</div>
</div>

@ -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('<wp-attachments work-package="workPackage"></wp-attachments>');
const scope = $rootScope.$new();
scope.workPackage = workPackage;
$compile(element)(scope);
scope.$digest();
element.isolateScope();
controller = element.controller('wpAttachments');
}));
});

@ -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(<number> 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(<number> wp.id, true);
});
});
}
public loadAttachments(refresh: boolean = true): ng.IPromise<any> {
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);
Loading…
Cancel
Save