Merge pull request #4391 from vvlladd28/feature/multiple-file-input

UI: Added multiple-file-input; Add support load multiple resource
This commit is contained in:
Igor Kulikov 2021-04-14 16:02:03 +03:00 committed by GitHub
commit 1142502e6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 174 additions and 51 deletions

View File

@ -18,10 +18,10 @@ import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { PageLink } from '@shared/models/page/page-link'; import { PageLink } from '@shared/models/page/page-link';
import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils';
import { Observable } from 'rxjs'; import { forkJoin, Observable, of } from 'rxjs';
import { PageData } from '@shared/models/page/page-data'; import { PageData } from '@shared/models/page/page-data';
import { Resource, ResourceInfo } from '@shared/models/resource.models'; import { Resource, ResourceInfo } from '@shared/models/resource.models';
import { map } from 'rxjs/operators'; import { catchError, map, mergeMap } from 'rxjs/operators';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -70,6 +70,25 @@ export class ResourceService {
); );
} }
public saveResources(resources: Resource[], config?: RequestConfig): Observable<Resource[]> {
let partSize = 100;
partSize = resources.length > partSize ? partSize : resources.length;
const resourceObservables: Observable<Resource>[] = [];
for (let i = 0; i < partSize; i++) {
resourceObservables.push(this.saveResource(resources[i], config).pipe(catchError(() => of({} as Resource))));
}
return forkJoin(resourceObservables).pipe(
mergeMap((resource) => {
resources.splice(0, partSize);
if (resources.length) {
return this.saveResources(resources, config);
} else {
return of(resource);
}
})
);
}
public saveResource(resource: Resource, config?: RequestConfig): Observable<Resource> { public saveResource(resource: Resource, config?: RequestConfig): Observable<Resource> {
return this.http.post<Resource>('/api/resource', resource, defaultHttpOptionsFromConfig(config)); return this.http.post<Resource>('/api/resource', resource, defaultHttpOptionsFromConfig(config));
} }

View File

@ -24,7 +24,6 @@ import {
import { Resolve } from '@angular/router'; import { Resolve } from '@angular/router';
import { Resource, ResourceInfo, ResourceTypeTranslationMap } from '@shared/models/resource.models'; import { Resource, ResourceInfo, ResourceTypeTranslationMap } from '@shared/models/resource.models';
import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models';
import { Direction } from '@shared/models/page/sort-order';
import { NULL_UUID } from '@shared/models/id/has-uuid'; import { NULL_UUID } from '@shared/models/id/has-uuid';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
@ -34,13 +33,14 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { Authority } from '@shared/models/authority.enum'; import { Authority } from '@shared/models/authority.enum';
import { ResourcesLibraryComponent } from '@home/pages/resource/resources-library.component'; import { ResourcesLibraryComponent } from '@home/pages/resource/resources-library.component';
import { Observable } from 'rxjs/internal/Observable'; import { PageLink } from '@shared/models/page/page-link';
import { PageData } from '@shared/models/page/page-data'; import { EntityAction } from '@home/models/entity/entity-component.models';
import { map } from 'rxjs/operators';
@Injectable() @Injectable()
export class ResourcesLibraryTableConfigResolver implements Resolve<EntityTableConfig<Resource>> { export class ResourcesLibraryTableConfigResolver implements Resolve<EntityTableConfig<Resource, PageLink, ResourceInfo>> {
private readonly config: EntityTableConfig<Resource> = new EntityTableConfig<Resource>(); private readonly config: EntityTableConfig<Resource, PageLink, ResourceInfo> = new EntityTableConfig<Resource, PageLink, ResourceInfo>();
private readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; private readonly resourceTypesTranslationMap = ResourceTypeTranslationMap;
constructor(private store: Store<AppState>, constructor(private store: Store<AppState>,
@ -52,17 +52,16 @@ export class ResourcesLibraryTableConfigResolver implements Resolve<EntityTableC
this.config.entityComponent = ResourcesLibraryComponent; this.config.entityComponent = ResourcesLibraryComponent;
this.config.entityTranslations = entityTypeTranslations.get(EntityType.TB_RESOURCE); this.config.entityTranslations = entityTypeTranslations.get(EntityType.TB_RESOURCE);
this.config.entityResources = entityTypeResources.get(EntityType.TB_RESOURCE); this.config.entityResources = entityTypeResources.get(EntityType.TB_RESOURCE);
this.config.defaultSortOrder = {property: 'title', direction: Direction.ASC};
this.config.entityTitle = (resource) => resource ? this.config.entityTitle = (resource) => resource ?
resource.title : ''; resource.title : '';
this.config.columns.push( this.config.columns.push(
new DateEntityTableColumn<ResourceInfo>('createdTime', 'common.created-time', this.datePipe, '150px'), new DateEntityTableColumn<ResourceInfo>('createdTime', 'common.created-time', this.datePipe, '150px'),
new EntityTableColumn<ResourceInfo>('title', 'widgets-bundle.title', '60%'), new EntityTableColumn<ResourceInfo>('title', 'resource.title', '60%'),
new EntityTableColumn<ResourceInfo>('resourceType', 'resource.resource-type', '40%', new EntityTableColumn<ResourceInfo>('resourceType', 'resource.resource-type', '40%',
entity => this.resourceTypesTranslationMap.get(entity.resourceType)), entity => this.resourceTypesTranslationMap.get(entity.resourceType)),
new EntityTableColumn<ResourceInfo>('tenantId', 'widgets-bundle.system', '60px', new EntityTableColumn<ResourceInfo>('tenantId', 'resource.system', '60px',
entity => { entity => {
return checkBoxCell(entity.tenantId.id === NULL_UUID); return checkBoxCell(entity.tenantId.id === NULL_UUID);
}), }),
@ -83,13 +82,34 @@ export class ResourcesLibraryTableConfigResolver implements Resolve<EntityTableC
this.config.deleteEntitiesTitle = count => this.translate.instant('resource.delete-resources-title', {count}); this.config.deleteEntitiesTitle = count => this.translate.instant('resource.delete-resources-title', {count});
this.config.deleteEntitiesContent = () => this.translate.instant('resource.delete-resources-text'); this.config.deleteEntitiesContent = () => this.translate.instant('resource.delete-resources-text');
this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink) as Observable<PageData<Resource>>; this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink);
this.config.loadEntity = id => this.resourceService.getResource(id.id); this.config.loadEntity = id => this.resourceService.getResource(id.id);
this.config.saveEntity = resource => this.resourceService.saveResource(resource); this.config.saveEntity = resource => this.saveResource(resource);
this.config.deleteEntity = id => this.resourceService.deleteResource(id.id); this.config.deleteEntity = id => this.resourceService.deleteResource(id.id);
this.config.onEntityAction = action => this.onResourceAction(action);
} }
resolve(): EntityTableConfig<Resource> { saveResource(resource) {
if (Array.isArray(resource.data)) {
const resources = [];
resource.data.forEach((data, index) => {
resources.push({
resourceType: resource.resourceType,
data,
fileName: resource.fileName[index],
title: resource.title
});
});
return this.resourceService.saveResources(resources, {resendRequest: true}).pipe(
map((response) => response[0])
);
} else {
return this.resourceService.saveResource(resource);
}
}
resolve(): EntityTableConfig<Resource, PageLink, ResourceInfo> {
this.config.tableTitle = this.translate.instant('resource.resources-library'); this.config.tableTitle = this.translate.instant('resource.resources-library');
const authUser = getCurrentAuthUser(this.store); const authUser = getCurrentAuthUser(this.store);
this.config.deleteEnabled = (resource) => this.isResourceEditable(resource, authUser.authority); this.config.deleteEnabled = (resource) => this.isResourceEditable(resource, authUser.authority);
@ -105,7 +125,16 @@ export class ResourcesLibraryTableConfigResolver implements Resolve<EntityTableC
this.resourceService.downloadResource(resource.id.id).subscribe(); this.resourceService.downloadResource(resource.id.id).subscribe();
} }
private isResourceEditable(resource: Resource, authority: Authority): boolean { onResourceAction(action: EntityAction<ResourceInfo>): boolean {
switch (action.action) {
case 'uploadResource':
this.exportResource(action.event, action.entity);
return true;
}
return false;
}
private isResourceEditable(resource: ResourceInfo, authority: Authority): boolean {
if (authority === Authority.TENANT_ADMIN) { if (authority === Authority.TENANT_ADMIN) {
return resource && resource.tenantId && resource.tenantId.id !== NULL_UUID; return resource && resource.tenantId && resource.tenantId.id !== NULL_UUID;
} else { } else {

View File

@ -16,6 +16,12 @@
--> -->
<div class="tb-details-buttons" fxLayout.xs="column"> <div class="tb-details-buttons" fxLayout.xs="column">
<button mat-raised-button color="primary" fxFlex.xs
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'uploadResource')"
[fxShow]="!isEdit">
{{'resource.export' | translate }}
</button>
<button mat-raised-button color="primary" fxFlex.xs <button mat-raised-button color="primary" fxFlex.xs
[disabled]="(isLoading$ | async)" [disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')" (click)="onEntityAction($event, 'delete')"
@ -44,9 +50,11 @@
<tb-file-input <tb-file-input
formControlName="data" formControlName="data"
required required
[convertToBase64]="true" [readAsBinary]="true"
[allowedExtensions]="getAllowedExtensions()" [allowedExtensions]="getAllowedExtensions()"
[contentConvertFunction]="convertToBase64File"
[accept]="getAcceptType()" [accept]="getAcceptType()"
[multipleFile]="entityForm.get('resourceType').value === resourceType.LWM2M_MODEL"
dropLabel="{{'resource.drop-file' | translate}}" dropLabel="{{'resource.drop-file' | translate}}"
[existingFileName]="entityForm.get('fileName')?.value" [existingFileName]="entityForm.get('fileName')?.value"
(fileNameChanged)="entityForm?.get('fileName').patchValue($event)"> (fileNameChanged)="entityForm?.get('fileName').patchValue($event)">

View File

@ -29,7 +29,7 @@ import {
ResourceTypeMIMETypes, ResourceTypeMIMETypes,
ResourceTypeTranslationMap ResourceTypeTranslationMap
} from '@shared/models/resource.models'; } from '@shared/models/resource.models';
import { distinctUntilChanged, takeUntil } from 'rxjs/operators'; import { pairwise, startWith, takeUntil } from 'rxjs/operators';
@Component({ @Component({
selector: 'tb-resources-library', selector: 'tb-resources-library',
@ -54,15 +54,22 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
ngOnInit() { ngOnInit() {
super.ngOnInit(); super.ngOnInit();
this.entityForm.get('resourceType').valueChanges.pipe( this.entityForm.get('resourceType').valueChanges.pipe(
distinctUntilChanged((oldValue, newValue) => [oldValue, newValue].includes(this.resourceType.LWM2M_MODEL)), startWith(ResourceType.LWM2M_MODEL),
pairwise(),
takeUntil(this.destroy$) takeUntil(this.destroy$)
).subscribe((type) => { ).subscribe(([previousType, type]) => {
if (previousType === this.resourceType.LWM2M_MODEL) {
this.entityForm.get('title').setValidators(Validators.required);
this.entityForm.get('title').updateValueAndValidity({emitEvent: false});
}
if (type === this.resourceType.LWM2M_MODEL) { if (type === this.resourceType.LWM2M_MODEL) {
this.entityForm.get('title').clearValidators(); this.entityForm.get('title').clearValidators();
} else { this.entityForm.get('title').updateValueAndValidity({emitEvent: false});
this.entityForm.get('title').setValidators(Validators.required);
} }
this.entityForm.get('title').updateValueAndValidity({emitEvent: false}); this.entityForm.patchValue({
data: null,
fileName: null
}, {emitEvent: false});
}); });
} }
@ -119,4 +126,8 @@ export class ResourcesLibraryComponent extends EntityComponent<Resource> impleme
return '*/*'; return '*/*';
} }
} }
convertToBase64File(data: string): string {
return window.btoa(data);
}
} }

View File

@ -18,7 +18,7 @@
<div class="tb-container"> <div class="tb-container">
<label class="tb-title">{{ label }}</label> <label class="tb-title">{{ label }}</label>
<ng-container #flow="flow" <ng-container #flow="flow"
[flowConfig]="{singleFile: true, allowDuplicateUploads: true}"> [flowConfig]="{allowDuplicateUploads: true}">
<div class="tb-file-select-container"> <div class="tb-file-select-container">
<div class="tb-file-clear-container"> <div class="tb-file-clear-container">
<button mat-button mat-icon-button color="primary" <button mat-button mat-icon-button color="primary"
@ -34,7 +34,7 @@
flowDrop flowDrop
[flow]="flow.flowJs"> [flow]="flow.flowJs">
<label for="{{inputId}}">{{ dropLabel }}</label> <label for="{{inputId}}">{{ dropLabel }}</label>
<input class="file-input" flowButton type="file" [flow]="flow.flowJs" [flowAttributes]="{accept: accept}" id="{{inputId}}"> <input class="file-input" flowButton #flowInput type="file" [flow]="flow.flowJs" [flowAttributes]="{accept: accept}" id="{{inputId}}">
</div> </div>
</div> </div>
</ng-container> </ng-container>

View File

@ -17,6 +17,7 @@
import { import {
AfterViewInit, AfterViewInit,
Component, Component,
ElementRef,
EventEmitter, EventEmitter,
forwardRef, forwardRef,
Input, Input,
@ -102,17 +103,34 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
existingFileName: string; existingFileName: string;
@Input() @Input()
convertToBase64 = false; readAsBinary = false;
private multipleFileValue = false;
@Input()
set multipleFile(value: boolean) {
this.multipleFileValue = value;
if (this.flow?.flowJs) {
this.updateMultipleFileMode(this.multipleFile);
}
}
get multipleFile(): boolean {
return this.multipleFileValue;
}
@Output() @Output()
fileNameChanged = new EventEmitter<string>(); fileNameChanged = new EventEmitter<string|string[]>();
fileName: string; fileName: string | string[];
fileContent: any; fileContent: any;
@ViewChild('flow', {static: true}) @ViewChild('flow', {static: true})
flow: FlowDirective; flow: FlowDirective;
@ViewChild('flowInput', {static: true})
flowInput: ElementRef;
autoUploadSubscription: Subscription; autoUploadSubscription: Subscription;
private propagateChange = null; private propagateChange = null;
@ -125,34 +143,60 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
ngAfterViewInit() { ngAfterViewInit() {
this.autoUploadSubscription = this.flow.events$.subscribe(event => { this.autoUploadSubscription = this.flow.events$.subscribe(event => {
if (event.type === 'fileAdded') { if (event.type === 'filesAdded') {
const file = event.event[0] as flowjs.FlowFile; const readers = [];
if (this.filterFile(file)) { (event.event[0] as flowjs.FlowFile[]).forEach(file => {
const reader = new FileReader(); if (this.filterFile(file)) {
reader.onload = (loadEvent) => { readers.push(this.readerAsFile(file));
if (typeof reader.result === 'string') { }
const fileContent = this.convertToBase64 ? window.btoa(reader.result) : reader.result; });
if (fileContent && fileContent.length > 0) { if (readers.length) {
if (this.contentConvertFunction) { Promise.all(readers).then((filesContent) => {
this.fileContent = this.contentConvertFunction(fileContent); filesContent = filesContent.filter(content => content.fileContent != null);
} else { if (filesContent.length === 1) {
this.fileContent = fileContent; this.fileContent = filesContent[0].fileContent;
} this.fileName = filesContent[0].fileName;
if (this.fileContent) { this.updateModel();
this.fileName = file.name; } else if (filesContent.length > 1) {
} else { this.fileContent = filesContent.map(content => content.fileContent);
this.fileName = null; this.fileName = filesContent.map(content => content.fileName);
} this.updateModel();
this.updateModel(); }
} });
}
}
});
if (!this.multipleFile) {
this.updateMultipleFileMode(this.multipleFile);
}
}
private readerAsFile(file: flowjs.FlowFile): Promise<any> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
let fileName = null;
let fileContent = null;
if (typeof reader.result === 'string') {
fileContent = reader.result;
if (fileContent && fileContent.length > 0) {
if (this.contentConvertFunction) {
fileContent = this.contentConvertFunction(fileContent);
}
if (fileContent) {
fileName = file.name;
} }
};
if (this.convertToBase64) {
reader.readAsBinaryString(file.file);
} else {
reader.readAsText(file.file);
} }
} }
resolve({fileContent, fileName});
};
reader.onerror = () => {
resolve({fileContent: null, fileName: null});
};
if (this.readAsBinary) {
reader.readAsBinaryString(file.file);
} else {
reader.readAsText(file.file);
} }
}); });
} }
@ -207,4 +251,11 @@ export class FileInputComponent extends PageComponent implements AfterViewInit,
this.fileContent = null; this.fileContent = null;
this.updateModel(); this.updateModel();
} }
private updateMultipleFileMode(multiple: boolean) {
this.flow.flowJs.opts.singleFile = !multiple;
if (!multiple) {
this.flowInput.nativeElement.removeAttribute('multiple');
}
}
} }

View File

@ -59,3 +59,8 @@ export interface Resource extends ResourceInfo {
data: string; data: string;
fileName: string; fileName: string;
} }
export interface Resources extends ResourceInfo {
data: string|string[];
fileName: string|string[];
}