Implement opFileUpload service in angular

pull/6346/head
Oliver Günther 6 years ago
parent 70a25ebc10
commit 4101248904
No known key found for this signature in database
GPG Key ID: 88872239EB414F99
  1. 40
      app/assets/stylesheets/content/work_packages/single_view/_attachments.sass
  2. 27
      app/assets/stylesheets/layout/_work_package_table.sass
  3. 1
      config/locales/js-en.yml
  4. 2
      frontend/src/app/angular4-modules.ts
  5. 74
      frontend/src/app/components/api/op-file-upload/op-file-upload.service.ts
  6. 20
      frontend/src/app/components/routing/ui-router.config.ts
  7. 62
      frontend/src/app/components/wp-attachments/wp-attachments-upload/wp-attachments-upload.component.ts
  8. 25
      frontend/src/app/components/wp-attachments/wp-attachments-upload/wp-attachments-upload.html
  9. 61
      frontend/src/app/globals/global-listeners.ts
  10. 9
      frontend/src/app/modules/common/notifications/notification.component.html
  11. 3
      frontend/src/app/modules/common/notifications/notification.component.ts
  12. 2
      frontend/src/app/modules/common/notifications/notifications.service.test.ts
  13. 5
      frontend/src/app/modules/common/notifications/notifications.service.ts
  14. 114
      frontend/src/app/modules/common/notifications/upload-progress.component.ts
  15. 21
      frontend/src/app/modules/hal/resources/work-package-resource.ts

@ -39,12 +39,6 @@
padding: 0
line-height: 20px
.add-file
float: left
padding: 8px 0 0 10px
i
padding: 0 2px 0 0
.upload-file
display: block
width: 100%
@ -53,9 +47,35 @@
padding: 20px 0 0 0
border-top: 1px solid #ddd
tr.is-droppable
background: $nm-color-success-background !important
.work-package--attachments--drop-box
border: 2px dashed $light-gray
border-radius: 2px
text-align: center
padding: 20px
cursor: pointer
&.-dragging
background: #f4f4f4
.work-package--attachments--label
color: $light-gray
text-align: center
i, label
display: inline-block
vertical-align: middle
.icon-attachment:before
color: $light-gray
font-size: 3rem
.is-droppable
border: 1px dotted $nm-color-success-border
label
cursor: pointer
color: $gray-dark
font-size: 0.9rem
font-weight: bold
line-height: 1.4
margin: 0 0 0 10px
text-align: left

@ -184,33 +184,6 @@
&:hover
text-decoration: none
.work-package--attachments--drop-box
border: 2px dashed $light-gray
border-radius: 2px
text-align: center
padding: 20px
cursor: pointer
.work-package--attachments--label
color: $light-gray
text-align: center
i, p
display: inline-block
vertical-align: middle
.icon-attachment:before
color: $light-gray
font-size: 3rem
p
color: $gray-dark
font-size: 0.9rem
font-weight: bold
line-height: 1.4
margin: 0 0 0 10px
text-align: left
.controller-work_packages
.icon-button, .sort-header, .action-icon
cursor: pointer

@ -659,6 +659,7 @@ en:
error_could_not_resolve_version_name: "Couldn't resolve version name"
error_could_not_resolve_user_name: "Couldn't resolve user name"
error_attachment_upload: "File '%{name}' failed to upload: %{error}"
units:
workPackage:

@ -205,6 +205,7 @@ import {MainMenuToggleComponent} from "core-components/resizer/main-menu-toggle.
import {MainMenuToggleService} from "core-components/resizer/main-menu-toggle.service";
import {IWorkPackageCreateServiceToken} from "core-components/wp-new/wp-create.service.interface";
import {IWorkPackageEditingServiceToken} from "core-components/wp-edit-form/work-package-editing.service.interface";
import {OpenProjectFileUploadService} from "core-components/api/op-file-upload/op-file-upload.service";
@NgModule({
imports: [
@ -259,6 +260,7 @@ import {IWorkPackageEditingServiceToken} from "core-components/wp-edit-form/work
WorkPackageFiltersService,
WorkPackageService,
ApiWorkPackagesService,
OpenProjectFileUploadService,
// Table and query states services
WorkPackageTableRelationColumnsService,
WorkPackageTablePaginationService,

@ -26,18 +26,30 @@
// See doc/COPYRIGHT.rdoc for more details.
//++
import {Injectable} from "@angular/core";
import {HttpClient, HttpEvent, HttpEventType, HttpResponse} from "@angular/common/http";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {Observable} from "rxjs/Observable";
import {filter, map, share} from "rxjs/operators";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
export interface UploadFile extends File {
description?:string;
customName?:string;
}
export type UploadHttpEvent = HttpEvent<HalResource>;
export type UploadInProgress = [UploadFile, Observable<UploadHttpEvent>];
export interface UploadResult {
uploads:Promise<any>[];
finished:Promise<any>;
uploads:UploadInProgress[];
finished:Promise<HalResource[]>;
}
@Injectable()
export class OpenProjectFileUploadService {
constructor(protected Upload:any) {
constructor(protected http:HttpClient,
protected halResource:HalResourceService) {
}
/**
@ -46,22 +58,60 @@ export class OpenProjectFileUploadService {
*/
public upload(url:string, files:UploadFile[]):UploadResult {
files = _.filter(files, (file:UploadFile) => file.type !== 'directory');
const uploads = _.map(files, (file:UploadFile) => {
const uploads:UploadInProgress[] = _.map(files, (file:UploadFile) => {
const formData = new FormData();
const metadata = {
description: file.description,
fileName: file.customName || file.name
};
// need to wrap the metadata into a JSON ourselves as ngFileUpload
// will otherwise break up the metadata into individual parts
const data = {
metadata: JSON.stringify(metadata),
file
};
// add the metadata object
formData.append(
'metadata',
JSON.stringify(metadata),
);
return this.Upload.upload({data, url});
// Add the file
formData.append('file', file);
const observable = this
.http
.post<HalResource>(
url,
formData,
{
// Observe the response, not the body
observe: 'response',
// Subscribe to progress events. subscribe() will fire multiple times!
reportProgress: true
}
)
.pipe(
share()
)
return [file, observable] as UploadInProgress;
});
const finished = Promise.all(uploads);
const finished = this.whenFinished(uploads);
return {uploads, finished} as any;
}
/**
* Create a promise for all uploaded responses when all uploads are fully uploaded.
*
* @param {UploadInProgress[]} uploads
*/
private whenFinished(uploads:UploadInProgress[]):Promise<HalResource[]> {
const promises = uploads.map(([_, observable]) => {
return observable
.pipe(
filter((evt) => evt.type === HttpEventType.Response),
map((evt:HttpResponse<HalResource>) => this.halResource.createHalResource(evt.body))
)
.toPromise();
});
return Promise.all(promises);
}
}

@ -209,26 +209,6 @@ export function initializeUiRouterConfiguration(injector:Injector) {
// Synchronize now that routes are updated
urlService.sync();
// Our application is still a hybrid one, meaning most routes are still
// handled by Rails. As such, we disable the default link-hijacking that
// Angular's HTML5-mode turns on.
jQuery(document.body)
.off('click')
// Prevent angular handling clicks on href="#..." links from other libraries
// (especially jquery-ui and its datepicker) from routing to <base url>/#
.on('click', 'a[href^="#"]', (evt) => {
evt.preventDefault();
// Set the location to the hash if there is any
// Since with the base tag, links like href="#whatever" otherwise target to <base>/#whatever
const link = evt.target.getAttribute('href');
if (link && link !== '#') {
window.location.hash = link;
}
return false;
});
$transitions.onStart({}, function(transition:Transition) {
const $state = transition.router.stateService;
const toParams = transition.params('to');

@ -30,7 +30,7 @@ import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-r
import {UploadFile} from '../../api/op-file-upload/op-file-upload.service';
import {ConfigurationService} from "core-app/modules/common/config/configuration.service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {Component, Input} from "@angular/core";
import {Component, ElementRef, Input, ViewChild} from "@angular/core";
@Component({
selector: 'wp-attachments-upload',
@ -38,6 +38,10 @@ import {Component, Input} from "@angular/core";
})
export class WorkPackageUploadComponent {
@Input() public workPackage:WorkPackageResource;
@ViewChild('hiddenFileInput') public filePicker:ElementRef;
public draggingOver:boolean = false;
public text:any;
public maxFileSize:number;
@ -53,7 +57,61 @@ export class WorkPackageUploadComponent {
});
}
public uploadFiles(files:UploadFile[]):void {
public triggerFileInput(event:MouseEvent) {
this.filePicker.nativeElement.click();
event.preventDefault();
event.stopPropagation();
return false;
}
public onDropFiles(event:DragEvent) {
event.dataTransfer.dropEffect = 'copy';
event.preventDefault();
event.stopPropagation();
let dfFiles = event.dataTransfer.files;
let length:number = dfFiles ? dfFiles.length : 0;
let files:UploadFile[] = [];
for (let i = 0; i < length; i++) {
files.push(dfFiles[i]);
}
this.uploadFiles(files);
this.draggingOver = false;
}
public onDragOver(event:DragEvent) {
if (this.containsFiles(event.dataTransfer)) {
event.dataTransfer.dropEffect = 'copy';
this.draggingOver = true;
}
event.preventDefault();
event.stopPropagation();
}
public onDragLeave(event:DragEvent) {
this.draggingOver = false;
event.preventDefault();
event.stopPropagation();
}
public onFilePickerChanged() {
const files:UploadFile[] = Array.from(this.filePicker.nativeElement.files);
this.uploadFiles(files);
}
private containsFiles(dataTransfer:any) {
if (dataTransfer.types.contains) {
return dataTransfer.types.contains('Files')
} else {
return (dataTransfer as DataTransfer).types.indexOf('Files') >= 0;
}
}
private uploadFiles(files:UploadFile[]):void {
if (files === undefined || files.length === 0) {
return;
}

@ -1,20 +1,27 @@
<div
class="wp-attachment-upload hide-when-print"
*ngIf="workPackage.canAddAttachments"
ngf-drop
ngf-select
ngf-change="uploadFiles($files)"
ngf-multiple="true"
ngf-validate="{ size: {max: maxFileSize} }"
tabindex="0"
(drop)="onDropFiles($event)"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
[attr.aria-label]="text.uploadLabel">
<div class="work-package--attachments--drop-box">
<input #hiddenFileInput
type="file"
id="attachment_files"
name="attachment_files"
(change)="onFilePickerChanged($event)"
hidden
multiple />
<div class="work-package--attachments--drop-box"
tabindex="0"
(accessibleClick)="triggerFileInput($event)"
[ngClass]="{ '-dragging': draggingOver }">
<div class="work-package--attachments--label">
<op-icon icon-classes="icon-attachment"></op-icon>
<p>
<label for="attachment_files">
{{ text.dropFiles }} <br>
{{ text.dropFilesHint }}
</p>
</label>
</div>
</div>
</div>

@ -0,0 +1,61 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2017 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-2017 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.
//++
/**
* A set of listeners that are relevant on every page to set sensible defaults
*/
(function($:JQueryStatic) {
// Our application is still a hybrid one, meaning most routes are still
// handled by Rails. As such, we disable the default link-hijacking that
// Angular's HTML5-mode turns on.
$(document.body)
.off('click')
// Prevent angular handling clicks on href="#..." links from other libraries
// (especially jquery-ui and its datepicker) from routing to <base url>/#
.on('click', 'a[href^="#"]', (evt) => {
evt.preventDefault();
// Set the location to the hash if there is any
// Since with the base tag, links like href="#whatever" otherwise target to <base>/#whatever
const link = evt.target.getAttribute('href');
if (link && link !== '#') {
window.location.hash = link;
}
return false;
});
// Disable global drag & drop handling, which results in the browser loading the image and losing the page
$(document.documentElement)
.on('dragover drop', (evt:JQueryEventObject) => {
evt.preventDefault();
return false;
});
}(jQuery));

@ -1,4 +1,4 @@
<div class="notification-box -{{ type }}" tabindex="0">
<div class="notification-box -{{ type }}" tabindex="0">
<div class="notification-box--content" role="alert" aria-atomic="true">
<p>
<span [textContent]="notification.message"></span>
@ -21,7 +21,12 @@
</div>
<div *ngIf="show || !canBeHidden()">
<ul class="notification-box--uploads" *ngIf="data && data.length > 0">
<notifications-upload-progress [upload]="upload" *ngFor="let upload of data"></notifications-upload-progress>
<notifications-upload-progress
*ngFor="let upload of data"
[upload]="upload"
(onSuccess)="onUploadSuccess()"
(onError)="onUploadError()">
</notifications-upload-progress>
</ul>
</div>
</div>

@ -86,9 +86,10 @@ export class NotificationComponent implements OnInit {
}
}
public onUploadError() {
public onUploadError(message:string) {
// Override the current type
this.type = 'error';
this.notification.message = message;
}
public onUploadSuccess() {

@ -82,7 +82,7 @@ describe('NotificationsService', function () {
});
it('should be able to create upload messages with uploads', function () {
var notification = notificationsService.addWorkPackageUpload('uploading...', [0, 1, 2]);
var notification = notificationsService.addWorkPackageUpload('uploading...', [0, 1, 2] as any);
expect(notification).to.eql({
message: 'uploading...',
type: 'upload',

@ -29,6 +29,7 @@
import {ConfigurationService} from 'core-app/modules/common/config/configuration.service';
import {input, State} from 'reactivestates';
import {Injectable} from '@angular/core';
import {UploadInProgress} from "core-components/api/op-file-upload/op-file-upload.service";
export function removeSuccessFlashMessages() {
jQuery('.flash.notice').remove();
@ -102,7 +103,7 @@ export class NotificationsService {
return this.add(this.createNotification(message, 'info'));
}
public addWorkPackageUpload(message:INotification|string, uploads:any[]) {
public addWorkPackageUpload(message:INotification|string, uploads:UploadInProgress[]) {
return this.add(this.createWorkPackageUploadNotification(message, uploads));
}
@ -127,7 +128,7 @@ export class NotificationsService {
return message;
}
private createWorkPackageUploadNotification(message:INotification|string, uploads:any[]) {
private createWorkPackageUploadNotification(message:INotification|string, uploads:UploadInProgress[]) {
if (!uploads.length) {
throw new Error('Cannot create an upload notification without uploads!');
}

@ -26,49 +26,107 @@
// See docs/COPYRIGHT.rdoc for more details.
//++
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
import {Component, EventEmitter, Input, OnDestroy, OnInit, Output} from '@angular/core';
import {
UploadFile,
UploadHttpEvent,
UploadInProgress
} from "core-components/api/op-file-upload/op-file-upload.service";
import {untilComponentDestroyed} from "ng2-rx-componentdestroyed";
import {HttpEventType, HttpProgressEvent} from "@angular/common/http";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {debugLog} from "core-app/helpers/debug_output";
import {HttpErrorResponse} from "@angular/common/http/src/response";
@Component({
selector: 'notifications-upload-progress',
template: `
<li>
<span class="filename" [textContent]="file"></span>
<progress [hidden]="completed" max="100" [value]="value">{{value}}%</progress>
<span class="upload-completed" *ngIf="completed || error">
<li>
<span class="filename" [textContent]="fileName"></span>
<progress [hidden]="completed" max="100" [value]="value">{{value}}%</progress>
<span class="upload-completed" *ngIf="completed || error">
<op-icon icon-classes="icon-close" *ngIf="error"></op-icon>
<op-icon icon-classes="icon-checkmark" *ngIf="completed"></op-icon>
</span>
</li>
</li>
`
})
export class UploadProgressComponent implements OnInit {
@Input() public upload:any;
@Output() public onError = new EventEmitter<any>();
@Output() public onSuccess = new EventEmitter<any>();
export class UploadProgressComponent implements OnInit, OnDestroy {
@Input() public upload:UploadInProgress;
@Output() public onError = new EventEmitter<string>();
@Output() public onSuccess = new EventEmitter<undefined>();
public file:string = '';
public file:UploadFile;
public value:number = 0;
public error:boolean = false;
public completed = false;
constructor(protected readonly I18n:I18nService) {
}
ngOnInit() {
this.upload.progress((details:any) => {
var file = details.config.file || details.config.data.file;
this.file = _.get(file, 'name', '');
if (details.lengthComputable) {
this.value = Math.round(details.loaded / details.total * 100);
} else {
// dummy value if not computable
this.value = 10;
}
}).success(() => {
this.value = 100;
this.completed = true;
this.onSuccess.emit();
}).error(() => {
this.error = true;
this.onError.emit();
});
this.file = this.upload[0];
const observable = this.upload[1];
observable
.pipe(
untilComponentDestroyed(this)
)
.subscribe(
(evt:UploadHttpEvent) => {
switch (evt.type) {
case HttpEventType.Sent:
this.value = 5;
return debugLog(`Uploading file "${this.file.name}" of size ${this.file.size}.`);
case HttpEventType.UploadProgress:
return this.updateProgress(evt);
case HttpEventType.Response:
debugLog(`File ${this.fileName} was fully uploaded.`);
this.value = 100;
this.completed = true;
return this.onSuccess.emit();
default:
// Sent or unknown event
return;
}
},
(error:HttpErrorResponse) => this.handleError(error, this.file)
);
}
ngOnDestroy() {
// Nothing to do.
}
public get fileName():string | undefined {
return this.file && this.file.name;
}
private updateProgress(evt:HttpProgressEvent) {
if (evt.total) {
this.value = Math.round(evt.loaded / evt.total * 100);
} else {
this.value = 10;
}
}
private handleError(error:HttpErrorResponse, file:UploadFile) {
let message:string;
debugLog(error);
if (error.error instanceof ErrorEvent) {
// A client-side or network error occurred.
message = this.I18n.t('js.error_attachment_upload', {name: file.name, error: error});
} else {
// The backend returned an unsuccessful response code.
message = error.error;
}
this.error = true;
this.onError.emit(message);
}
}

@ -133,6 +133,7 @@ export class WorkPackageResource extends HalResource {
WorkPackageNotificationService);
readonly wpCreate:WorkPackageCreateService = this.injector.get(WorkPackageCreateService);
readonly pathHelper:PathHelperService = this.injector.get(PathHelperService);
readonly opFileUpload:OpenProjectFileUploadService = this.injector.get(OpenProjectFileUploadService);
public get id():string {
return this.$source.id || this.idFromLink;
@ -211,30 +212,23 @@ export class WorkPackageResource extends HalResource {
* Upload the given attachments, update the resource and notify the user.
* Return an updated AttachmentCollectionResource.
*/
public uploadAttachments(files:UploadFile[]):Promise<any> {
public uploadAttachments(files:UploadFile[]):Promise<{ response:HalResource, uploadUrl:string }[]> {
const { uploads, finished } = this.performUpload(files);
const message = I18n.t('js.label_upload_notification', this);
const notification = this.NotificationsService.addWorkPackageUpload(message, uploads);
return finished
.then((result:any[]) => {
.then((result:HalResource[]) => {
setTimeout(() => this.NotificationsService.remove(notification), 700);
if (!this.isNew) {
this.updateAttachments();
} else {
result.forEach(r => {
let attachment = new HalResource(this.injector,
r.data,
false,
this.halInitializer,
'HalResource');
this.attachments.elements.push(attachment);
this.attachments.elements.push(r);
});
}
return result.map(el => { return { response: el.data, uploadUrl: el.data._links.downloadLocation.href }; });
return result.map((el:HalResource) => { return { response: el, uploadUrl: el.downloadLocation.href }; });
})
.catch((error:any) => {
this.wpNotificationsService.handleRawError(error, this as any);
@ -251,10 +245,7 @@ export class WorkPackageResource extends HalResource {
href = this.attachments.$href!;
}
// TODO upgrade
//const opFileUpload:OpenProjectFileUploadService = jQuery('body').injector().get('opFileUpload');
// return opFileUpload.upload(href, files);
return Promise.reject(undefined) as any;
return this.opFileUpload.upload(href, files);
}
public getSchemaName(name:string):string {

Loading…
Cancel
Save