From 46a229bb67f7b3d57f3e30e20668b94e7355c51e Mon Sep 17 00:00:00 2001 From: Sean Engelhardt Date: Thu, 21 Feb 2019 16:48:19 +0100 Subject: [PATCH] Add mediafiles upload from motion form Refactors the media file upload component into a shared component to be used in both normal pages and dialogs. A dialog was addes into the media file "edit" view to upload and set attachments on the fly. --- .../media-upload-content.component.html | 80 ++++++ .../media-upload-content.component.scss | 62 ++++ .../media-upload-content.component.spec.ts | 25 ++ .../media-upload-content.component.ts | 265 ++++++++++++++++++ client/src/app/shared/shared.module.ts | 7 +- .../media-upload/media-upload.component.html | 92 +----- .../media-upload/media-upload.component.scss | 62 ---- .../media-upload/media-upload.component.ts | 239 +--------------- .../motion-detail.component.html | 24 +- .../motion-detail.component.scss | 11 + .../motion-detail/motion-detail.component.ts | 39 ++- 11 files changed, 525 insertions(+), 381 deletions(-) create mode 100644 client/src/app/shared/components/media-upload-content/media-upload-content.component.html create mode 100644 client/src/app/shared/components/media-upload-content/media-upload-content.component.scss create mode 100644 client/src/app/shared/components/media-upload-content/media-upload-content.component.spec.ts create mode 100644 client/src/app/shared/components/media-upload-content/media-upload-content.component.ts diff --git a/client/src/app/shared/components/media-upload-content/media-upload-content.component.html b/client/src/app/shared/components/media-upload-content/media-upload-content.component.html new file mode 100644 index 000000000..f90f4e59c --- /dev/null +++ b/client/src/app/shared/components/media-upload-content/media-upload-content.component.html @@ -0,0 +1,80 @@ +
+ + +
+ + Drop files into this area OR select files + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Title + + + + File name{{ file.filename }}File information +
+ insert_drive_file {{ file.mediafile.type }} + + data_usage + {{ getReadableSize(file.mediafile.size) }} + +
+
Hidden + + Remove + +
+
+
+ + +
+ + +
+ + + + + diff --git a/client/src/app/shared/components/media-upload-content/media-upload-content.component.scss b/client/src/app/shared/components/media-upload-content/media-upload-content.component.scss new file mode 100644 index 000000000..2ebb80aad --- /dev/null +++ b/client/src/app/shared/components/media-upload-content/media-upload-content.component.scss @@ -0,0 +1,62 @@ +.table-container { + overflow: auto; + + table { + width: 100%; + + /** Title */ + .mat-column-title { + min-width: 100px; + + .mat-form-field { + width: 95%; + } + } + + /** Filename */ + .mat-column-filename { + min-width: 100px; + } + + /** Information */ + .mat-column-information { + min-width: 100px; + } + + /** Hidden */ + .mat-column-hidden { + min-width: 100px; + } + + /** remove */ + .mat-column-remove { + min-width: 100px; + } + + .file-info-cell { + display: grid; + margin: 0; + + span { + .mat-icon { + font-size: 130%; + } + } + } + } +} + +.selection-area { + margin-bottom: 1em; + cursor: pointer; +} + +.upload-area { + margin-bottom: 1em; +} + +.action-buttons { + button + button { + margin-left: 1em; + } +} diff --git a/client/src/app/shared/components/media-upload-content/media-upload-content.component.spec.ts b/client/src/app/shared/components/media-upload-content/media-upload-content.component.spec.ts new file mode 100644 index 000000000..1bbab2ec6 --- /dev/null +++ b/client/src/app/shared/components/media-upload-content/media-upload-content.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MediaUploadContentComponent } from './media-upload-content.component'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('MediaUploadContentComponent', () => { + let component: MediaUploadContentComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MediaUploadContentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/media-upload-content/media-upload-content.component.ts b/client/src/app/shared/components/media-upload-content/media-upload-content.component.ts new file mode 100644 index 000000000..4f69b2216 --- /dev/null +++ b/client/src/app/shared/components/media-upload-content/media-upload-content.component.ts @@ -0,0 +1,265 @@ +import { Component, OnInit, ViewChild, Input, Output, EventEmitter } from '@angular/core'; +import { MatTableDataSource, MatTable } from '@angular/material'; + +import { UploadEvent, FileSystemFileEntry } from 'ngx-file-drop'; + +import { OperatorService } from 'app/core/core-services/operator.service'; +import { MediafileRepositoryService } from 'app/core/repositories/mediafiles/mediafile-repository.service'; + +/** + * To hold the structure of files to upload + */ +interface FileData { + mediafile: File; + filename: string; + title: string; + uploader_id: number; + hidden: boolean; +} + +@Component({ + selector: 'os-media-upload-content', + templateUrl: './media-upload-content.component.html', + styleUrls: ['./media-upload-content.component.scss'] +}) +export class MediaUploadContentComponent implements OnInit { + /** + * Columns to display in the upload-table + */ + public displayedColumns: string[] = ['title', 'filename', 'information', 'hidden', 'remove']; + + /** + * Determine wether to show the progress bar + */ + public showProgress = false; + + /** + * Consumable data source for the table + */ + public uploadList: MatTableDataSource; + + /** + * Holds the IDs of the uploaded files + */ + private filesUploadedIds: number[] = []; + + /** + * Determine if uploading should happen parallel or synchronously. + * Synchronous uploading might be necessary if we see that stuff breaks + */ + @Input() + public parallel = true; + + /** + * Set if an error was detected to prevent automatic navigation + */ + public errorMessage: string; + + /** + * Hold the mat table to manually render new rows + */ + @ViewChild(MatTable) + public table: MatTable; + + /** + * Emits an event on success + */ + @Output() + public uploadSuccessEvent = new EventEmitter(); + + /** + * Emits an error event + */ + @Output() + public errorEvent = new EventEmitter(); + + /** + * Constructor for the media upload page + * + * @param repo the mediafile repository + * @param op the operator, to check who was the uploader + */ + public constructor(private repo: MediafileRepositoryService, private op: OperatorService) {} + + /** + * Init + * Creates a new uploadList as consumable data source + */ + public ngOnInit(): void { + this.uploadList = new MatTableDataSource(); + } + + /** + * Converts given FileData into FormData format and hands it over to the repository + * to upload + * + * @param fileData the file to upload to the server, should fit to the FileData interface + */ + public async uploadFile(fileData: FileData): Promise { + const input = new FormData(); + input.set('mediafile', fileData.mediafile); + input.set('title', fileData.title); + input.set('uploader_id', '' + fileData.uploader_id); + input.set('hidden', '' + fileData.hidden); + + // raiseError will automatically ignore existing files + await this.repo.uploadFile(input).then( + fileId => { + this.filesUploadedIds.push(fileId.id); + // remove the uploaded file from the array + this.onRemoveButton(fileData); + }, + error => { + this.errorMessage = error; + } + ); + } + + /** + * Converts a file size in bit into human readable format + * + * @param bits file size in bits + * @returns a readable file size representation + */ + public getReadableSize(bits: number): string { + const unitLevel = Math.floor(Math.log(bits) / Math.log(1024)); + const bytes = +(bits / Math.pow(1024, unitLevel)).toFixed(2); + return `${bytes} ${['B', 'kB', 'MB', 'GB', 'TB'][unitLevel]}`; + } + + /** + * Change event to set a file to hidden or not + * + * @param hidden whether the file should be hidden + * @param file the given file + */ + public onChangeHidden(hidden: boolean, file: FileData): void { + file.hidden = hidden; + } + + /** + * Change event to adjust the title + * + * @param newTitle the new title + * @param file the given file + */ + public onChangeTitle(newTitle: string, file: FileData): void { + file.title = newTitle; + } + + /** + * Add a file to list to upload later + * + * @param file the file to upload + */ + public addFile(file: File): void { + const newFile: FileData = { + mediafile: file, + filename: file.name, + title: file.name, + uploader_id: this.op.user.id, + hidden: false + }; + this.uploadList.data.push(newFile); + + if (this.table) { + this.table.renderRows(); + } + } + + /** + * Handler for the select file event + * + * @param $event holds the file. Triggered by changing the file input element + */ + public onSelectFile(event: any): void { + if (event.target.files && event.target.files.length > 0) { + // file list is a special kind of collection, so array.foreach does not apply + for (const addedFile of event.target.files) { + this.addFile(addedFile); + } + } + } + + /** + * Handler for the drop-file event + * + * @param event holds the file. Triggered by dropping in the area + */ + public onDropFile(event: UploadEvent): void { + for (const droppedFile of event.files) { + // Check if the dropped element is a file. "Else" would be a dir. + if (droppedFile.fileEntry.isFile) { + const fileEntry = droppedFile.fileEntry as FileSystemFileEntry; + fileEntry.file((file: File) => { + this.addFile(file); + }); + } + } + } + + /** + * Click handler for the upload button. + * Iterate over the upload list and executes `uploadFile` on each element + */ + public async onUploadButton(): Promise { + if (this.uploadList && this.uploadList.data.length > 0) { + this.errorMessage = ''; + this.showProgress = true; + + if (this.parallel) { + const promises = this.uploadList.data.map(file => this.uploadFile(file)); + await Promise.all(promises); + } else { + for (const file of this.uploadList.data) { + await this.uploadFile(file); + } + } + this.showProgress = false; + + if (this.errorMessage === '') { + this.uploadSuccessEvent.next(this.filesUploadedIds); + } else { + this.table.renderRows(); + const filenames = this.uploadList.data.map(file => file.filename); + this.errorEvent.next(`${this.errorMessage}\n${filenames}`); + } + } + } + + /** + * Calculate the progress to display in the progress bar + * Only used in synchronous upload since parallel upload + * + * @returns the upload progress in percent. + */ + public calcUploadProgress(): number { + if (this.filesUploadedIds && this.filesUploadedIds.length > 0 && this.uploadList.data) { + return 100 / (this.uploadList.data.length / this.filesUploadedIds.length); + } else { + return 0; + } + } + + /** + * Removes the given file from the upload table + * + * @param file the file to remove + */ + public onRemoveButton(file: FileData): void { + if (this.uploadList.data) { + this.uploadList.data.splice(this.uploadList.data.indexOf(file), 1); + this.table.renderRows(); + } + } + + /** + * Click handler for the clear button. Deletes the upload list + */ + public onClearButton(): void { + this.uploadList.data = []; + if (this.table) { + this.table.renderRows(); + } + } +} diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 302f930d3..bef140be2 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -83,6 +83,7 @@ import { OpenSlidesTranslateModule } from '../core/translate/openslides-translat import { ProjectorComponent } from './components/projector/projector.component'; import { SlideContainerComponent } from './components/slide-container/slide-container.component'; import { CountdownTimeComponent } from './components/contdown-time/countdown-time.component'; +import { MediaUploadContentComponent } from './components/media-upload-content/media-upload-content.component'; /** * Share Module for all "dumb" components and pipes. @@ -203,7 +204,8 @@ import { CountdownTimeComponent } from './components/contdown-time/countdown-tim SlideContainerComponent, OwlDateTimeModule, OwlNativeDateTimeModule, - CountdownTimeComponent + CountdownTimeComponent, + MediaUploadContentComponent ], declarations: [ PermsDirective, @@ -230,7 +232,8 @@ import { CountdownTimeComponent } from './components/contdown-time/countdown-tim MetaTextBlockComponent, ProjectorComponent, SlideContainerComponent, - CountdownTimeComponent + CountdownTimeComponent, + MediaUploadContentComponent ], providers: [ { provide: DateAdapter, useClass: OpenSlidesDateAdapter }, diff --git a/client/src/app/site/mediafiles/components/media-upload/media-upload.component.html b/client/src/app/site/mediafiles/components/media-upload/media-upload.component.html index 535f79368..c41f3ebfd 100644 --- a/client/src/app/site/mediafiles/components/media-upload/media-upload.component.html +++ b/client/src/app/site/mediafiles/components/media-upload/media-upload.component.html @@ -1,6 +1,6 @@ -

Upload files

+

Upload files

@@ -671,8 +676,9 @@ -
+
+
@@ -868,3 +877,14 @@ Final print template + + + +

+ Upload Files +

+ +
diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss b/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss index eb35c9829..a9605a1f9 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss @@ -177,3 +177,14 @@ span { font-size: 12px; margin-top: 4px; } + +.shortened-selector { + justify-content: space-between; + display: flex; + .selector { + width: 95%; + } + .mat-icon-button { + top: 20px; + } +} diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts index 93768140b..a51691469 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts @@ -1,5 +1,5 @@ import { ActivatedRoute, Router } from '@angular/router'; -import { Component, OnInit, ElementRef, HostListener } from '@angular/core'; +import { Component, OnInit, ElementRef, HostListener, TemplateRef } from '@angular/core'; import { DomSanitizer, SafeHtml, Title } from '@angular/platform-browser'; import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms'; import { MatDialog, MatSnackBar, MatCheckboxChange, ErrorStateMatcher } from '@angular/material'; @@ -344,8 +344,8 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { * @param pdfExport export the motion to pdf * @param personalNoteService: personal comments and favorite marker * @param linenumberingService The line numbering service - * @param categoryRepo - * @param userRepo + * @param categoryRepo Repository for categories + * @param userRepo Repository for users */ public constructor( title: Title, @@ -1257,6 +1257,39 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { this.personalNoteService.setPersonalNoteStar(this.motion.motion, !this.motion.star); } + /** + * Handler for the upload attachments button + */ + public onUploadAttachmentsButton(templateRef: TemplateRef): void { + this.dialogService.open(templateRef, { + maxHeight: '90vh', + width: '750px', + maxWidth: '90vw' + }); + } + + /** + * Handler for successful uploads. + * Adds the IDs of the upload process to the mediafile selector + * + * @param fileIds the ids of the uploads if they were successful + */ + public uploadSuccess(fileIds: number[]): void { + const currentAttachments = this.contentForm.get('attachments_id').value as number[]; + const newAttachments = [...currentAttachments, ...fileIds]; + this.contentForm.get('attachments_id').setValue(newAttachments); + this.dialogService.closeAll(); + } + + /** + * Handler for upload errors + * + * @param error the error message passed by the upload component + */ + public showUploadError(error: string): void { + this.raiseError(error); + } + /** * Function to prevent automatically closing the window/tab, * if the user is editing a motion.