Adding attachments to work package descriptions by drag and drop

https://community.openproject.org/work_packages/22511

This adds drag and drop functionality for wiki-textareas concerning wp-attachments (mainly the description field) in workpackages.

based on the use of our new directives by @furinvader as proposed in https://github.com/opf/openproject/pull/4471

Features:

handles drop events on description fields of work packages and can distinguish between:
- local files: initiates file upload (single file or upload queue)
- external urls: become inserted as default weblink
- external image files: become inserted as inline image
- attachments from current work package: become inserted as attachment link
- attachments (imagefiles) from current work package: become inserted as inline image
pull/4540/head
manuschiller 9 years ago committed by Oliver Günther
parent 2d617bfd0c
commit d9a38f8b89
  1. 131
      frontend/app/components/work-packages/wp-attachments-formattable-field/wp-attachments-formattable.directive.ts
  2. 14
      frontend/app/components/work-packages/wp-attachments-formattable-field/wp-attachments-formattable.enums.ts
  3. 9
      frontend/app/components/work-packages/wp-attachments-formattable-field/wp-attachments-formattable.interfaces.ts
  4. 184
      frontend/app/components/work-packages/wp-attachments-formattable-field/wp-attachments-formattable.models.ts
  5. 40
      frontend/app/components/work-packages/wp-attachments/wp-attachments-edit.directive.html
  6. 4
      frontend/app/components/work-packages/wp-attachments/wp-attachments.directive.html
  7. 41
      frontend/app/components/work-packages/wp-attachments/wp-attachments.directive.test.ts
  8. 217
      frontend/app/components/work-packages/wp-attachments/wp-attachments.directive.ts
  9. 21
      frontend/app/components/work-packages/wp-attachments/wp-attachments.service.test.ts
  10. 182
      frontend/app/components/work-packages/wp-attachments/wp-attachments.service.ts
  11. 4
      frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.html
  12. 82
      frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.ts
  13. 4
      frontend/app/components/wp-table/wp-table.directive.html

@ -0,0 +1,131 @@
import {WpAttachmentsService} from './../wp-attachments/wp-attachments.service';
import {WorkPackageResource} from './../../api/api-v3/hal-resources/work-package-resource.service'
import {InsertMode, ViewMode} from './wp-attachments-formattable.enums'
import {DropModel, EditorModel, MarkupModel, FieldModel, SingleAttachmentModel} from './wp-attachments-formattable.models'
export class WpAttachmentsFormattableController {
private viewMode: ViewMode = ViewMode.SHOW;
constructor(protected $scope: ng.IScope,
protected $element: ng.IAugmentedJQuery,
protected $rootScope: ng.IRootScopeService,
protected $location: ng.ILocationService,
protected wpAttachments: WpAttachmentsService,
protected $timeout: ng.ITimeoutService) {
$element.get(0).addEventListener('drop', this.handleDrop);
$element.bind('dragenter', this.prevDefault)
.bind('dragleave', this.prevDefault)
.bind('dragover', this.prevDefault);
}
public handleDrop = (evt: DragEvent): void => {
evt.preventDefault();
evt.stopPropagation();
const textarea: ng.IAugmentedJQuery = this.$element.find('textarea');
this.viewMode = (textarea.length > 0) ? ViewMode.EDIT : ViewMode.SHOW;
const workPackage: WorkPackageResource = this.$scope.workPackage;
const dropData: DropModel = new DropModel(this.$location, evt.dataTransfer, workPackage);
var description: any;
if (this.viewMode === ViewMode.EDIT) {
description = new EditorModel(textarea, new MarkupModel());
}
else {
description = new FieldModel(workPackage, new MarkupModel());
}
if (angular.isUndefined(dropData.webLinkUrl) && angular.isUndefined(dropData.files))
return;
if(dropData.isUpload){
if(dropData.filesAreValidForUploading()) {
if(!dropData.isDelayedUpload) {
this.wpAttachments.upload(workPackage, dropData.files).then(() => {
this.wpAttachments.load(workPackage,true).then((updatedAttachments: any) => {
if (angular.isUndefined(updatedAttachments))
return;
updatedAttachments.sort(function(a, b){
return a.id > b.id ? 1 : -1;
});
if (dropData.filesCount === 1) {
const currentFile: SingleAttachmentModel =
new SingleAttachmentModel(updatedAttachments[updatedAttachments.length - 1]);
description.insertAttachmentLink(
currentFile.url,
(currentFile.isAnImage) ? InsertMode.INLINE : InsertMode.ATTACHMENT);
}
else if (dropData.filesCount > 1) {
for (let i: number = updatedAttachments.length - 1;
i >= updatedAttachments.length - dropData.filesCount;
i--) {
description.insertAttachmentLink(
updatedAttachments[i]._links.downloadLocation.href,
InsertMode.ATTACHMENT,
true);
}
}
description.save();
},(err)=>{
console.log("error while reloading attachments",err)
});
}, function(err) {
console.log(err);
});
}
else {
dropData.files.forEach((file: File) => {
description.insertAttachmentLink(file.name.replace(/ /g , '_'), InsertMode.ATTACHMENT, true);
file['isPending'] = true;
this.wpAttachments.addPendingAttachments(file);
});
description.save();
}
}
}
else {
const insertUrl: string = dropData.isAttachmentOfCurrentWp() ? dropData.removeHostInformationFromUrl() : dropData.webLinkUrl;
const insertAlternative: InsertMode = dropData.isWebImage() ? InsertMode.INLINE : InsertMode.LINK;
const insertMode: InsertMode = dropData.isAttachmentOfCurrentWp() ? InsertMode.ATTACHMENT : insertAlternative;
description.insertWebLink(insertUrl, insertMode);
description.save();
}
};
protected prevDefault(evt: DragEvent): void {
evt.preventDefault();
evt.stopPropagation();
}
}
function wpAttachmentsFormattable() {
return {
bindToController: true,
controller: WpAttachmentsFormattableController,
link: function(scope: ng.IScope,
element: ng.IAugmentedJQuery,
attrs: ng.IAttributes,
controllers: Array<ng.IControllerService>){
// right now the attachments directive will only work in combination with either
// the wpSingleView or the wpEditForm directive
// else the drop handler will fail because of a missing reference to the current wp
if(angular.isUndefined(controllers[0] && angular.isUndefined(controllers[1])))
return;
scope.workPackage = (controllers[0] === null) ? controllers[1].workPackage : controllers[0].workPackage;
},
require: ['?^wpSingleView', '?^wpEditForm'],
restrict: 'A'
};
}
angular
.module('openproject')
.directive('wpAttachmentsFormattable', wpAttachmentsFormattable);

@ -0,0 +1,14 @@
export enum InsertMode {
ATTACHMENT,
DELAYED_ATTACHMENT,
INLINE,
LINK
}
export enum ViewMode {
EDIT,
SHOW,
CREATE
}

@ -0,0 +1,9 @@
import {InsertMode} from './wp-attachments-formattable.enums'
export interface IApplyAttachmentMarkup{
contentToInsert: string;
insertAttachmentLink: (url: string,insertMode: InsertMode, addLineBreak?:boolean) => void;
insertWebLink: (url: string,insertMode: InsertMode) => void;
save: () => void;
}

@ -0,0 +1,184 @@
import {IApplyAttachmentMarkup} from './wp-attachments-formattable.interfaces'
import {InsertMode} from './wp-attachments-formattable.enums'
import {WorkPackageResource} from "../../api/api-v3/hal-resources/work-package-resource.service";
import IAugmentedJQuery = angular.IAugmentedJQuery;
export class EditorModel implements IApplyAttachmentMarkup{
private currentCaretPosition: number;
public contentToInsert:string = "";
constructor(protected textarea: IAugmentedJQuery, protected markupModel: MarkupModel){
this.setCaretPosition();
}
public insertWebLink(url: string, insertMode: InsertMode = InsertMode.LINK): void {
this.contentToInsert = this.markupModel.createMarkup(url, insertMode);
};
public insertAttachmentLink(url: string, insertMode: InsertMode = InsertMode.ATTACHMENT, addLineBreak?: boolean): void {
this.contentToInsert = (addLineBreak) ?
this.contentToInsert + this.markupModel.createMarkup(url, insertMode, addLineBreak) :
this.markupModel.createMarkup(url, insertMode, addLineBreak);
};
private setCaretPosition(): void {
this.currentCaretPosition = (this.textarea[0] as HTMLTextAreaElement).selectionStart;
};
public save(): void {
this.textarea.val(this.textarea.val().substring(0, this.currentCaretPosition) +
this.contentToInsert +
this.textarea.val().substring(this.currentCaretPosition, this.textarea.val().length)).change();
}
}
export class MarkupModel{
public createMarkup(insertUrl: string, insertMode: InsertMode, addLineBreak?: boolean = false): string {
if (angular.isUndefined((insertUrl))) return "";
var markup:string = "";
switch (insertMode) {
case InsertMode.ATTACHMENT:
markup = "attachment:" + insertUrl.split("/").pop();
break;
case InsertMode.DELAYED_ATTACHMENT:
markup = "attachment:" + insertUrl;
break;
case InsertMode.INLINE:
markup = "!" + insertUrl + "!";
break;
case InsertMode.LINK:
markup += insertUrl;
break;
}
if(addLineBreak) markup += "\r\n";
return markup;
}
}
export class DropModel{
public files: FileList;
public filesCount: number;
public isUpload: boolean;
public isDelayedUpload: boolean;
public isWebLink: boolean;
public webLinkUrl: string;
protected config: any = {
imageFileTypes : ["jpg","jpeg","gif","png"],
maximumAttachmentFileSize : 0, // initialized during init process from ConfigurationService
};
constructor(protected $location: ng.ILocationService, protected dt: DataTransfer, protected workPackage: WorkPackageResource){
this.files = dt.files;
this.filesCount = this.files.length;
this.isUpload = this._isUpload(dt);
this.isDelayedUpload = this.workPackage.isNew;
this.isWebLink = ! this.isUpload;
this.webLinkUrl = dt.getData("URL");
}
public isWebImage(): boolean {
if(angular.isDefined(this.webLinkUrl)){
return (this.config.imageFileTypes.indexOf(this.webLinkUrl.split(".").pop().toLowerCase()) > -1);
}
};
public isAttachmentOfCurrentWp():boolean {
if(this.isWebLink){
// weblink does not point to our server, so it can't be an attachment
if(!(this.webLinkUrl.indexOf(this.$location.host()) > -1) ) return false;
var isAttachment:boolean = false;
this.workPackage.attachments.elements.forEach((attachment)=>{
if(this.webLinkUrl.indexOf(attachment.href) > -1) {
isAttachment = true;
return;
}
});
return isAttachment;
}
};
public filesAreValidForUploading(): boolean {
// needs: clarifying if rejected filetypes are a wanted feature
// no filetypes are getting rejected yet
var allFilesAreValid = true;
/*this.files.forEach((file)=>{
if(file.size > this.config.maximumAttachmentFileSize) {
allFilesAreValid = false;
return;
}
});*/
return allFilesAreValid;
};
public removeHostInformationFromUrl(): string {
return this.webLinkUrl.replace(window.location.origin, "");
};
protected _isUpload(dt: DataTransfer): boolean {
if (dt.types && this.filesCount > 0) {
for (let i=0; i < dt.types.length; i++) {
if (dt.types[i] == "Files") {
return true;
}
}
}
return false;
}
}
export class SingleAttachmentModel {
protected imageFileExtensions: Array<string> = ['jpeg','jpg','gif','bmp','png'];
public fileExtension: string;
public fileName: string;
public isAnImage: boolean;
public url: string;
constructor(protected attachment: any){
if(angular.isDefined(attachment)){
this.fileName = attachment.fileName || attachment.name;
this.fileExtension = this.fileName.split(".").pop().toLowerCase();
this.isAnImage = this.imageFileExtensions.indexOf(this.fileExtension) > -1;
this.url = angular.isDefined(attachment._links) ? attachment._links.downloadLocation.href : '';
}
}
}
export class FieldModel implements IApplyAttachmentMarkup {
public contentToInsert: string;
constructor(protected workPackage: WorkPackageResource, protected markupModel: MarkupModel){
this.contentToInsert = workPackage.description.raw || "";
}
private addInitialLineBreak(): string {
return (this.contentToInsert.length > 0) ? "\r\n" : "";
};
public insertAttachmentLink(url: string, insertMode: InsertMode, addLineBreak?: boolean): void {
this.contentToInsert += this.addInitialLineBreak() + this.markupModel.createMarkup(url,insertMode,false);
};
public insertWebLink(url: string, insertMode: InsertMode): void {
this.contentToInsert += this.addInitialLineBreak() + this.markupModel.createMarkup(url,insertMode,false);
};
public save(): void {
this.workPackage.description.raw = this.contentToInsert;
this.workPackage.save();
};
}

@ -2,38 +2,38 @@
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text">
{{ ::I18n.t('js.label_attachments') }}
{{ ::vm.I18n.t('js.label_attachments') }}
</h3>
</div>
</div>
<div class="work-package--attachments--files"
data-ng-show="attachments.length > 0">
data-ng-show="vm.attachments.length > 0">
<div class="work-package--details--long-field">
<span class="inplace-edit--read"
data-ng-repeat="attachment in attachments">
data-ng-repeat="attachment in vm.attachments track by attachment.id">
<span class="inplace-editing--trigger-container">
<span class="inplace-editing--trigger-link"
ng-class="{'-focus': focussing(attachment)}">
ng-class="{'-focus': vm.focussing(attachment)}">
<span class="inplace-editing--container">
<span class="inplace-edit--read-value">
<i class="icon-attachment"></i>
<a class="work-package--attachments--filename"
data-ng-href="{{attachment._links.downloadLocation.href}}"
data-ng-href="{{attachment._links.downloadLocation.href || '#'}}"
download
data-ng-focus="focus(attachment)"
data-ng-blur="focus(null)">
{{::attachment.fileName}}
data-ng-focus="vm.focus(attachment)"
data-ng-blur="vm.focus(null)">
{{::attachment.fileName || attachment.name}}
</a>
</span>
<a href=''
class="inplace-edit--icon-wrapper"
data-ng-focus="focus(attachment)"
data-ng-blur="focus(null)"
data-ng-click="remove(attachment)"
data-confirm-popup="{{I18n.t('js.text_attachment_destroy_confirmation')}}">
data-ng-focus="vm.focus(attachment)"
data-ng-blur="vm.focus(null)"
data-ng-click="vm.remove(attachment)"
data-confirm-popup="{{vm.I18n.t('js.text_attachment_destroy_confirmation')}}">
<icon-wrapper icon-name="delete"
data-icon-title="{{::I18n.t('js.label_remove_file',
data-icon-title="{{::vm.I18n.t('js.label_remove_file',
{ fileName: attachment.fileName })}}">
</icon-wrapper>
</a>
@ -43,22 +43,22 @@
</span>
</div>
</div>
<div data-ng-show="hasRightToUpload"
<div data-ng-show="vm.hasRightToUpload"
data-ngf-drop
data-ng-model="files"
data-ng-model-rejected="rejectedFiles"
data-ng-model="vm.files"
data-ng-model-rejected="vm.rejectedFiles"
data-ngf-select
class="work-package--attachments--drop-box"
data-ngf-multiple="true"
data-ngf-change="uploadFilteredFiles($files)"
data-ngf-max-size="{{::maximumFileSize}}"
data-ngf-change="vm.uploadFilteredFiles($files)"
data-ngf-max-size="{{::vm.maximumFileSize}}"
data-click-on-keypress="[13, 32]">
<div class="work-package--attachments--label">
<i class="icon-attachment"></i>
<p>
{{ ::I18n.t('js.label_drop_files') }}
{{ ::vm.I18n.t('js.label_drop_files') }}
<br>
{{ ::I18n.t('js.label_drop_files_hint') }}
{{ ::vm.I18n.t('js.label_drop_files_hint') }}
</p>
</div>
</div>

@ -7,10 +7,10 @@
</div>
</div>
<div class="work-package--attachments--files"
data-ng-show="files.length > 0">
data-ng-show="attachments.length > 0">
<div class="work-package--details--long-field">
<span class="inplace-edit--read"
data-ng-repeat="attachment in files">
data-ng-repeat="attachment in attachments track by $index">
<span class="inplace-editing--trigger-container">
<span class="inplace-editing--trigger-link"
ng-class="{'-focus': focussing(attachment)}">

@ -25,9 +25,11 @@
//
// See doc/COPYRIGHT.rdoc for more details.
//++
import {WorkPackageAttachmentsController} from './wp-attachments.directive'
describe('WorkPackageAttachmentsDirective', function() {
var compile;
var controller: WorkPackageAttachmentsController;
var element;
var rootScope;
var scope;
@ -38,18 +40,21 @@ describe('WorkPackageAttachmentsDirective', function() {
beforeEach(angular.mock.module('openproject.templates'));
var loadPromise,
wpAttachments = {
load: function() {
return loadPromise;
},
upload: angular.noop
wpAttachments = {
load: function() {
return loadPromise;
},
apiPromise,
configurationService = {
api: function() {
return apiPromise;
}
};
getCurrentAttachments: function(){
return [];
},
upload: angular.noop
},
apiPromise,
configurationService = {
api: function() {
return apiPromise;
}
};
beforeEach(angular.mock.module('openproject.workPackages.services', function($provide) {
$provide.constant('wpAttachments', wpAttachments);
@ -86,12 +91,13 @@ describe('WorkPackageAttachmentsDirective', function() {
beforeEach(function() {
compile();
isolatedScope = element.isolateScope();
controller = element.controller('wpAttachments');
});
it('filters out attachments of type directory', function() {
var files = [{type: 'directory'}, {type: 'file'}];
isolatedScope.filterFiles(files, {}, {}, false);
controller.filterFiles(files);
expect(files).to.eql([{type: 'file'}]);
});
@ -100,22 +106,23 @@ describe('WorkPackageAttachmentsDirective', function() {
describe('uploadFilteredFiles', function() {
var files = [{type: 'directory'}, {type: 'file'}],
dumbPromise = {
then: function(call) { return call(); }
};
dumbPromise = {
then: function(call) { return call(); }
};
beforeEach(function() {
compile();
isolatedScope = element.isolateScope();
controller = element.controller('wpAttachments');
});
it('triggers uploading of non directory files', function() {
//need to have files to be able to trigger uploads
isolatedScope.files = files;
controller.files = files;
var uploadStub = wpAttachments.upload = sinon.stub().returns(dumbPromise);
isolatedScope.uploadFilteredFiles(files, {}, {}, true);
controller.uploadFilteredFiles(files);
expect(uploadStub.calledWith(workPackage, [{type: 'file'}])).to.be.true;
});

@ -26,133 +26,134 @@
// See doc/COPYRIGHT.rdoc for more details.
//++
import {wpDirectivesModule} from "../../../angular-modules";
function wpAttachmentsDirective(wpAttachments,
NotificationsService,
I18n,
ConfigurationService,
ConversionService) {
var editMode = function (attrs) {
return !angular.isUndefined(attrs.edit);
import {wpDirectivesModule} from '../../../angular-modules';
import {WpAttachmentsService} from './wp-attachments.service'
export class WorkPackageAttachmentsController{
public workPackage: any;
public attachments: Array = [];
public fetchingConfiguration: boolean = false;
public files: Array<File> = [];
public hasRightToUpload: boolean = false;
public I18n: any;
public loading: boolean = false;
public rejectedFiles: Array = [];
public settings: Object = {
maximumFileSize: Number
};
function WorkPackageAttachmentsController(scope, element, attrs) {
scope.files = [];
scope.element = element;
public size: any;
var workPackage = scope.workPackage(),
upload = function (event, workPackage) {
if (angular.isUndefined(scope.files)) {
return;
}
if (scope.files.length > 0) {
wpAttachments.upload(workPackage, scope.files).then(function () {
scope.files = [];
loadAttachments();
});
}
},
loadAttachments = function () {
if (!editMode(attrs)) {
return;
}
scope.loading = true;
wpAttachments.load(workPackage, true).then(function (attachments) {
scope.attachments = attachments;
}).finally(function () {
scope.loading = false;
});
};
scope.I18n = I18n;
scope.rejectedFiles = [];
scope.size = ConversionService.fileSize;
scope.hasRightToUpload = !!(workPackage.$links.addAttachment || workPackage.isNew);
var currentlyRemoving = [];
scope.remove = function (file) {
currentlyRemoving.push(file);
wpAttachments.remove(file).then(function (file) {
_.remove(scope.attachments, file);
_.remove(scope.files, file);
}).finally(function () {
_.remove(currentlyRemoving, file);
});
};
private currentlyFocussing;
private editMode: boolean;
scope.deleting = function (attachment) {
return _.findIndex(currentlyRemoving, attachment) > -1;
};
constructor(protected $scope: ng.IScope,
protected $element: ng.IAugmentedJQuery,
protected $attrs: ng.IAttributes,
protected wpAttachments: WpAttachmentsService,
protected NotificationsService: ng.IServiceProvider,
protected I18n: any,
protected ConfigurationService: ng.IServiceProviderFactory,
protected ConversionService: ng.IServiceProvider){
var currentlyFocusing = null;
this.attachments = this.wpAttachments.getCurrentAttachments();
this.editMode = $attrs.hasOwnProperty('edit');
this.workPackage = $scope.vm.workPackage();
scope.focus = function (attachment) {
currentlyFocusing = attachment;
};
this.hasRightToUpload = !!(angular.isDefined(this.workPackage.addAttachment) || this.workPackage.isNew);
scope.focussing = function (attachment) {
return currentlyFocusing === attachment;
};
this.fetchingConfiguration = true;
ConfigurationService.api().then(settings => {
this.settings.maximumFileSize = settings.maximumAttachmentFileSize;
this.fetchingConfiguration = false;
});
if(angular.isDefined(this.workPackage)) {
this.loadAttachments();
}
scope.$on('uploadPendingAttachments', upload);
}
scope.filterFiles = function (files) {
// Directories cannot be uploaded and as such, should not become files in
// the sense of this directive. The files within the direcotories will
// be taken though.
_.remove(files, (file:any) => {
return file.type === 'directory';
public upload(): void {
if (this.workPackage.isNew) {
this.files.forEach((file) => {
this.attachments.push(file);
});
};
scope.uploadFilteredFiles = function (files) {
scope.filterFiles(files);
scope.$emit('uploadPendingAttachments', workPackage);
};
scope.$watch('rejectedFiles', function (rejectedFiles) {
if (rejectedFiles.length === 0) {
return;
}
var errors = _.map(rejectedFiles, (file:any) => {
return file.name + ' (' + scope.size(file.size) + ')';
}),
message = I18n.t('js.label_rejected_files_reason',
{maximumFilesize: scope.size(scope.maximumFileSize)}
);
NotificationsService.addError(message, errors);
});
return;
}
if (this.files.length > 0) {
this.wpAttachments.upload(this.workPackage, this.files).then(() => {
this.files = [];
this.loadAttachments();
});
}
};
public loadAttachments(): void {
if (this.editMode) {
this.loading = true;
this.wpAttachments.load(this.workPackage,true).finally(() => {
this.loading = false;
});
}
};
public remove(file): void {
if(this.workPackage.isNew){
_.remove(this.wpAttachments.attachments, file);
}
else{
this.wpAttachments.remove(file);
}
};
public focus(attachment: any): void {
this.currentlyFocussing = attachment;
};
public focussing(attachment: any): boolean {
return this.currentlyFocussing === attachment;
};
scope.fetchingConfiguration = true;
ConfigurationService.api().then(function (settings) {
scope.maximumFileSize = settings.maximumAttachmentFileSize;
// somehow, I18n cannot interpolate function results, so we need to cache this once
scope.maxFileSize = scope.size(settings.maximumAttachmentFileSize);
scope.fetchingConfiguration = false;
public filterFiles(files): void {
// Directories cannot be uploaded and as such, should not become files in
// the sense of this directive. The files within the directories will
// be taken though.
_.remove(files, (file:any) => {
return file.type === 'directory';
});
};
loadAttachments();
public uploadFilteredFiles(files): void {
this.filterFiles(files);
this.upload()
}
}
function wpAttachmentsDirective(): ng.IDirective {
return {
restrict: 'E',
bindToController: true,
controller: WorkPackageAttachmentsController,
controllerAs: 'vm',
replace: true,
restrict: 'E',
scope: {
workPackage: '&'
},
templateUrl: (element, attrs) => {
if (editMode(attrs)) {
return '/components/work-packages/wp-attachments/wp-attachments-edit.directive.html';
}
return '/components/work-packages/wp-attachments/wp-attachments.directive.html';
workPackage: '&',
},
link: WorkPackageAttachmentsController
};
templateUrl: (element: ng.IAugmentedJQuery, attrs: ng.IAttributes): string => {
if(attrs.hasOwnProperty('edit')) {
return '/components/work-packages/wp-attachments/wp-attachments-edit.directive.html';
}
else {
return '/components/work-packages/wp-attachments/wp-attachments.directive.html';
}
}
}
}
wpDirectivesModule.directive('wpAttachments', wpAttachmentsDirective);

@ -34,15 +34,14 @@ describe('wpAttachments', () => {
var workPackage = {
id: 1,
$links: {
attachments: {
$link: {
href: '/api/v3/work_packages/1/attachments'
}
$isHal: true,
attachments: {
href: '/api/v3/work_packages/1/attachments',
},
addAttachment: {
$link: {
href: '/api/v3/work_packages/1/attachments'
$links: {
attachments:{
$link:{
href:'/api/v3/work_packages/1/attachments'
}
}
}
@ -50,9 +49,11 @@ describe('wpAttachments', () => {
// mock me an attachment
var attachment = {
id: 1,
_type: "Attachment",
_links: {
self: {
href: '/attachments/1234'
href: "/api/v3/attachments/1"
}
}
};
@ -112,7 +113,7 @@ describe('wpAttachments', () => {
describe('deleting an attachment', () => {
beforeEach(() => {
$httpBackend.expectDELETE('/attachments/1234').respond({});
$httpBackend.expectDELETE('/api/v3/attachments/1').respond({});
});
it('should remove an attachment', () => {

@ -27,83 +27,117 @@
// ++
import {wpServicesModule} from '../../../angular-modules.ts';
import ArrayLiteralExpression = ts.ArrayLiteralExpression;
import {WorkPackageResource} from './../../api/api-v3/hal-resources/work-package-resource.service'
import {HalResource} from './../../api/api-v3/hal-resources/hal-resource.service'
export class WpAttachmentsService {
public attachments: Array = [];
function wpAttachmentsService($q, $timeout, $http, Upload, I18n, NotificationsService) {
var upload = (workPackage, files) => {
var uploadPath = workPackage.$links.addAttachment.$link.href;
var uploads = _.map(files, (file:any) => {
var options = {
url: uploadPath,
fields: {
metadata: {
fileName: file.name,
description: file.description
}
},
file: file
};
return Upload.upload(options);
});
constructor(
protected $q: ng.IQService,
protected $timeout: ng.ITimeoutService,
protected $http: ng.IHttpProvider,
protected Upload,
protected I18n,
protected NotificationsService
) {}
// notify the user
var message = I18n.t('js.label_upload_notification', {
id: workPackage.id,
subject: workPackage.subject
});
public upload(workPackage: WorkPackageResource, files: FileList): ng.IPromise {
const uploadPath: string = workPackage.$links.attachments.$link.href;
const uploads = _.map(files, (file: File) => {
var options: Object = {
fields: {
metadata: {
description: file.description,
fileName: file.name,
}
},
file: file,
url: uploadPath
};
return this.Upload.upload(options);
});
var notification = NotificationsService.addWorkPackageUpload(message, uploads);
var allUploadsDone = $q.defer();
$q.all(uploads).then(function () {
$timeout(function () { // let the notification linger for a bit
NotificationsService.remove(notification);
allUploadsDone.resolve();
}, 700);
}, function (err) {
allUploadsDone.reject(err);
});
return allUploadsDone.promise;
},
load = function (workPackage, reload:boolean = false) {
var path = workPackage.$links.attachments.$link.href,
attachments = $q.defer();
$http.get(path, {cache: !reload}).success(function (response) {
attachments.resolve(response._embedded.elements);
}).error(function (err) {
attachments.reject(err);
});
return attachments.promise;
},
remove = function (fileOrAttachment) {
var removal = $q.defer();
if (angular.isObject(fileOrAttachment._links)) {
var path = fileOrAttachment._links.self.href;
$http.delete(path).success(function () {
removal.resolve(fileOrAttachment);
}).error(function (err) {
removal.reject(err);
});
} else {
removal.resolve(fileOrAttachment);
}
return removal.promise;
},
hasAttachments = function (workPackage) {
var existance = $q.defer();
load(workPackage).then(function (attachments:any) {
existance.resolve(attachments.length > 0);
});
return existance.promise;
};
return {
upload: upload,
remove: remove,
load: load,
hasAttachments: hasAttachments
// notify the user
const message = this.I18n.t('js.label_upload_notification', {
id: workPackage.id,
subject: workPackage.subject
});
const notification = this.NotificationsService.addWorkPackageUpload(message, uploads);
const allUploadsDone = this.$q.defer();
this.$q.all(uploads).then(() => {
this.$timeout(() => { // let the notification linger for a bit
this.NotificationsService.remove(notification);
allUploadsDone.resolve();
}, 700);
}, function (err) {
allUploadsDone.reject(err);
});
return allUploadsDone.promise;
}
public load(workPackage: WorkPackageResource, reload:boolean = false): ng.IPromise<Array> {
const loadedAttachments = this.$q.defer();
const path: string = workPackage.$links.attachments.$link.href;
this.$http.get(path, {cache: !reload}).success(response => {
_.remove(this.attachments);
_.extend(this.attachments,response._embedded.elements);
loadedAttachments.resolve(this.attachments);
}).error(err => {
loadedAttachments.reject(err);
});
return loadedAttachments.promise;
};
public remove(fileOrAttachment: any): void {
if (fileOrAttachment._type === "Attachment") {
const path: string = fileOrAttachment._links.self.href;
this.$http.delete(path).success(() => {
_.remove(this.attachments, fileOrAttachment);
})
}else{
// pending attachment
_.remove(this.attachments, fileOrAttachment);
}
};
public hasAttachments(workPackage: WorkPackageResource): ng.IPromise {
const existance = this.$q.defer();
this.load(workPackage).then((attachments:any) => {
existance.resolve(attachments.length > 0);
});
return existance.promise;
};
public getCurrentAttachments(): Array<any> {
return this.attachments;
};
public resetAttachmentsList(): void {
this.attachments.length = 0;
};
public addPendingAttachments(files: FileList | File): void {
if (angular.isArray(files)) {
files.forEach(file => {
this.attachments.push(file);
});
}
else {
this.attachments.push(files);
}
}
// not in use until furinvaders create is merged
public uploadPendingAttachments = (wp: WorkPackageResource): ng.IPromise<any> => {
if (angular.isDefined(wp) && this.attachments.length > 0){
return this.upload(wp, this.attachments);
}
}
}
wpServicesModule.factory('wpAttachments', wpAttachmentsService);
wpServicesModule.service('wpAttachments', WpAttachmentsService);

@ -15,6 +15,7 @@
<div
wp-edit-field="'description'"
wp-attachments-formattable
wp-edit-field-wrapper-classes="'-no-label'"
display-placeholder="$ctrl.I18n.t('js.work_packages.placeholders.description')"
class="single-attribute wiki">
@ -101,10 +102,11 @@
</div>
</div>
</div>
</div>
<wp-attachments
edit
work-package="$ctrl.workPackage"
data-ng-show="!$ctrl.hideEmptyFields || $ctrl.filesExist">
data-ng-show="!$ctrl.hideEmptyFields || $ctrl.filesExist()">
</wp-attachments>
</div>

@ -28,14 +28,17 @@
import {opWorkPackagesModule} from "../../../angular-modules";
import {scopedObservable} from "../../../helpers/angular-rx-utils";
import {WorkPackageResource} from "../../api/api-v3/hal-resources/work-package-resource.service";
export class WorkPackageSingleViewController {
public workPackage;
public workPackage:WorkPackageResource | any;
public singleViewWp;
public groupedFields:any[] = [];
public hideEmptyFields:boolean = true;
public filesExist:boolean = false;
public filesExist : () => boolean;
public attachments: any;
public text:any;
public scope:any;
protected firstTimeFocused:boolean = false;
@ -55,40 +58,21 @@ export class WorkPackageSingleViewController {
this.groupedFields = WorkPackagesOverviewService.getGroupedWorkPackageOverviewAttributes();
this.text = {
fields: {
date: { startDate: I18n.t('js.label_no_start_date'),
dueDate: I18n.t('js.label_no_due_date') }
date: {
startDate: I18n.t('js.label_no_start_date'),
dueDate: I18n.t('js.label_no_due_date')
}
}
};
scopedObservable($scope, wpCacheService.loadWorkPackage($stateParams.workPackageId)).subscribe(wp => {
this.workPackage = wp;
this.singleViewWp = new SingleViewWorkPackage(wp);
this.workPackage.schema.$load().then(schema => {
this.setFocus();
var otherGroup:any = _.find(this.groupedFields, {groupName: 'other'});
otherGroup.attributes = [];
angular.forEach(schema, (prop, propName) => {
if (propName.match(/^customField/)) {
otherGroup.attributes.push(propName);
}
});
otherGroup.attributes.sort((leftField, rightField) => {
var getLabel = field => this.singleViewWp.getLabel(field);
var left = getLabel(leftField).toLowerCase();
var right = getLabel(rightField).toLowerCase();
return left.localeCompare(right);
});
if ($stateParams.workPackageId) {
scopedObservable($scope, wpCacheService.loadWorkPackage($stateParams.workPackageId)).subscribe(wp => {
this.init(wp);
});
wpAttachments.hasAttachments(this.workPackage).then(bool => {
this.filesExist = bool;
});
});
}
else if (this.workPackage) {
this.init(this.workPackage);
}
$scope.$on('workPackageUpdatedInEditor', () => {
NotificationsService.addSuccess({
@ -103,6 +87,10 @@ export class WorkPackageSingleViewController {
});
}
public filesExist = function(){
return this.wpAttachments.getCurrentAttachments().length > 0
};
public shouldHideGroup(group) {
return this.singleViewWp.shouldHideGroup(this.hideEmptyFields, this.groupedFields, group);
}
@ -118,6 +106,32 @@ export class WorkPackageSingleViewController {
angular.element('.work-packages--details--subject .focus-input').focus();
}
}
private init(wp) {
this.workPackage = wp;
this.singleViewWp = new this.SingleViewWorkPackage(wp);
this.workPackage.schema.$load().then(schema => {
this.setFocus();
var otherGroup:any = _.find(this.groupedFields, {groupName: 'other'});
otherGroup.attributes = [];
angular.forEach(schema, (prop, propName) => {
if (propName.match(/^customField/)) {
otherGroup.attributes.push(propName);
}
});
otherGroup.attributes.sort((leftField, rightField) => {
var getLabel = field => this.singleViewWp.getLabel(field);
var left = getLabel(leftField).toLowerCase();
var right = getLabel(rightField).toLowerCase();
return left.localeCompare(right);
});
});
}
}
function wpSingleViewDirective() {
@ -125,7 +139,9 @@ function wpSingleViewDirective() {
restrict: 'E',
templateUrl: '/components/work-packages/wp-single-view/wp-single-view.directive.html',
scope: {},
scope: {
workPackage: '=?'
},
bindToController: true,
controller: WorkPackageSingleViewController,

@ -120,8 +120,8 @@
wp-edit-form="row.object"
wp-edit-form-on-error="handleErroneousColumns(workPackage, fields, attributes)"
wp-edit-form-on-save="onWorkPackageSave(workPackage, fields)">
wp-edit-form-on-save="onWorkPackageSave(workPackage, fields)"
wp-attachments-formattable>
<td ng-if="!row.object.isNew" class="checkbox -short hide-when-print">
<accessible-checkbox name="ids[]"
checkbox-id="work_package{{row.object.id}}"

Loading…
Cancel
Save