TSK-1711: Task routing upload (#1729)

* TSK-1711: create task-routing module, routing upload component, file drag and drop directive

* TSK-1711: update drag and drop hover effect

* Update routing-upload.service.ts

* TSK-1711: Implement integration with backend

* TSK-1711: fix linting and tests

* TSK-1711: Add guards for task-routing Route, update REST endpoints

* TSK-1711: fix unit tests breaking

* TSK-1711: should not display unknown error for 404
This commit is contained in:
Chi Nguyen 2021-11-01 09:56:13 +01:00 committed by GitHub
parent 2892d4a2cb
commit d922d8a178
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 338 additions and 12 deletions

View File

@ -65,6 +65,11 @@ const routes: Routes = [
redirectTo: ''
}
]
},
{
path: 'task-routing',
canActivate: [DomainGuard],
loadChildren: () => import('../task-routing/task-routing.module').then((m) => m.TaskRoutingModule)
}
]
},

View File

@ -7,6 +7,8 @@
<a mat-tab-link class="administration-overview__navbar-links" routerLink="/taskana/administration/access-items-management"
[active]="selectedTab == 'access-items-management'" (click)="selectedTab = 'access-items-management'">Access Items
Management</a>
<a mat-tab-link class="administration-overview__navbar-links" routerLink="/taskana/administration/task-routing"
[active]="selectedTab == 'task-routing'" (click)="selectedTab = 'task-routing'" *ngIf="(routingAccess$ | async)">Task Routing</a>
</nav>
<div class="administration-overview__domain">
<mat-form-field appearance="legacy">

View File

@ -8,6 +8,7 @@ import { DomainService } from '../../../shared/services/domain/domain.service';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { of } from 'rxjs';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { TaskanaEngineService } from '../../../shared/services/taskana-engine/taskana-engine.service';
const domainServiceSpy: Partial<DomainService> = {
getDomains: jest.fn().mockReturnValue(of(['domain a', 'domain b'])),
@ -30,7 +31,7 @@ describe('AdministrationOverviewComponent', () => {
BrowserAnimationsModule
],
declarations: [AdministrationOverviewComponent],
providers: [{ provide: DomainService, useValue: domainServiceSpy }]
providers: [{ provide: DomainService, useValue: domainServiceSpy }, TaskanaEngineService]
}).compileComponents();
}));

View File

@ -1,8 +1,9 @@
import { Component, Input, OnInit } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { Observable, Subject } from 'rxjs';
import { Observable, of, Subject } from 'rxjs';
import { DomainService } from '../../../shared/services/domain/domain.service';
import { takeUntil } from 'rxjs/operators';
import { TaskanaEngineService } from '../../../shared/services/taskana-engine/taskana-engine.service';
@Component({
selector: 'taskana-administration-overview',
@ -16,8 +17,13 @@ export class AdministrationOverviewComponent implements OnInit {
destroy$ = new Subject<void>();
url$: Observable<any>;
routingAccess$: Observable<boolean> = of(false);
constructor(private router: Router, private domainService: DomainService) {
constructor(
private router: Router,
private domainService: DomainService,
private taskanaEngineService: TaskanaEngineService
) {
router.events.pipe(takeUntil(this.destroy$)).subscribe((e) => {
const urlPaths = this.router.url.split('/');
if (this.router.url.includes('detail')) {
@ -29,6 +35,8 @@ export class AdministrationOverviewComponent implements OnInit {
}
ngOnInit() {
this.routingAccess$ = this.taskanaEngineService.isCustomRoutingRulesEnabled$;
this.routingAccess$.pipe(takeUntil(this.destroy$)).subscribe();
this.domainService
.getDomains()
.pipe(takeUntil(this.destroy$))

View File

@ -9,6 +9,8 @@
(click)="toggleSidenav()">Classifications</a>
<a mat-list-item class="navlist__item navlist__admin-access-items" [routerLink]=[accessUrl]
[routerLinkActive]="['active__sub']" (click)="toggleSidenav()" *ngIf="administrationAccess">Access Items</a>
<a mat-list-item class="navlist__item navlist__admin-task-routing" [routerLink]=[routingUrl]
[routerLinkActive]="['active__sub']" (click)="toggleSidenav()" *ngIf="(routingAccess$ | async)">Task Routing</a>
</div>
<a mat-list-item class="navlist__item navlist__monitor" [routerLink]=[monitorUrl] [routerLinkActive]="['active']"
*ngIf="monitorAccess" (click)="toggleSidenav()">Monitor</a>

View File

@ -8,7 +8,6 @@ import { HttpClientTestingModule } from '@angular/common/http/testing';
import { RouterModule } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { TaskanaEngineService } from '../../services/taskana-engine/taskana-engine.service';
import { TaskanaEngineServiceMock } from '../../services/taskana-engine/taskana-engine.mock.service';
import { MatButtonModule } from '@angular/material/button';
import { MatSidenavModule } from '@angular/material/sidenav';
@ -22,9 +21,10 @@ const SidenavServiceSpy: Partial<SidenavService> = {
toggleSidenav: jest.fn().mockReturnValue(EMPTY)
};
const TaskanaEngineServiceSpy: Partial<TaskanaEngineServiceMock> = {
const TaskanaEngineServiceSpy: Partial<TaskanaEngineService> = {
hasRole: jest.fn().mockReturnValue(EMPTY),
isHistoryProviderEnabled: jest.fn().mockReturnValue(EMPTY)
isHistoryProviderEnabled: jest.fn().mockReturnValue(EMPTY),
isCustomRoutingRulesEnabled$: EMPTY
};
describe('SidenavListComponent', () => {

View File

@ -4,6 +4,8 @@ import { MonitorGuard } from 'app/shared/guards/monitor.guard';
import { UserGuard } from 'app/shared/guards/user.guard';
import { TaskanaEngineService } from '../../services/taskana-engine/taskana-engine.service';
import { SidenavService } from '../../services/sidenav/sidenav.service';
import { Observable, of } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'taskana-sidenav-list',
@ -17,6 +19,7 @@ export class SidenavListComponent implements OnInit {
workplaceUrl = 'taskana/workplace';
historyUrl = 'taskana/history';
accessUrl = 'taskana/administration/access-items-management';
routingUrl = 'taskana/administration/task-routing';
classificationUrl = 'taskana/administration/classifications';
workbasketsUrl = 'taskana/administration/workbaskets';
administrationsUrl = 'taskana/administration/workbaskets';
@ -26,6 +29,7 @@ export class SidenavListComponent implements OnInit {
monitorAccess = false;
workplaceAccess = false;
historyAccess = false;
routingAccess$: Observable<boolean> = of(false);
settingsAccess = false;
constructor(private taskanaEngineService: TaskanaEngineService, private sidenavService: SidenavService) {}
@ -37,6 +41,8 @@ export class SidenavListComponent implements OnInit {
this.taskanaEngineService.isHistoryProviderEnabled().subscribe((value) => {
this.historyAccess = value;
});
this.routingAccess$ = this.taskanaEngineService.isCustomRoutingRulesEnabled$;
this.routingAccess$.subscribe();
this.settingsAccess = this.administrationAccess;
}

View File

@ -0,0 +1,8 @@
import { DragAndDropDirective } from './drag-and-drop.directive';
describe('DragAndDropDirective', () => {
it('should create an instance', () => {
const directive = new DragAndDropDirective();
expect(directive).toBeTruthy();
});
});

View File

@ -0,0 +1,34 @@
import { Directive, HostListener, Output, EventEmitter, HostBinding } from '@angular/core';
@Directive({
selector: '[taskanaDragAndDrop]'
})
export class DragAndDropDirective {
@Output() onFileDropped = new EventEmitter<any>();
@HostBinding('class.fileover') fileOver: boolean;
//Dragover listener
@HostListener('dragover', ['$event']) onDragOver(evt) {
evt.preventDefault();
evt.stopPropagation();
this.fileOver = true;
}
//Dragleave listener
@HostListener('dragleave', ['$event']) public onDragLeave(evt) {
evt.preventDefault();
evt.stopPropagation();
this.fileOver = false;
}
//Drop listener
@HostListener('drop', ['$event']) public ondrop(evt) {
evt.preventDefault();
evt.stopPropagation();
this.fileOver = false;
let files = evt.dataTransfer.files;
if (files.length > 0) {
this.onFileDropped.emit(files);
}
}
}

View File

@ -0,0 +1,34 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { Observable, of } from 'rxjs';
import { TaskanaEngineService } from '../services/taskana-engine/taskana-engine.service';
import { catchError, map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class TaskRoutingGuard implements CanActivate {
constructor(private taskanaEngineService: TaskanaEngineService, public router: Router) {}
canActivate(
next: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean> | Promise<boolean> | boolean {
return this.taskanaEngineService.isCustomRoutingRulesEnabled$.pipe(
map((value) => {
if (value) {
return value;
}
return this.navigateToWorkplace();
}),
catchError(() => {
return of(this.navigateToWorkplace());
})
);
}
navigateToWorkplace(): boolean {
this.router.navigate(['workplace']);
return false;
}
}

View File

@ -3,6 +3,7 @@ import {
HttpErrorResponse,
HttpEvent,
HttpHandler,
HttpHeaders,
HttpInterceptor,
HttpRequest,
HttpXsrfTokenExtractor
@ -22,7 +23,13 @@ export class HttpClientInterceptor implements HttpInterceptor {
) {}
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
let req = request.clone({ setHeaders: { 'Content-Type': 'application/hal+json' } });
let req = request.clone();
if (req.headers.get('Content-Type') === 'multipart/form-data') {
const headers = new HttpHeaders();
req = req.clone({ headers });
} else {
req = req.clone({ setHeaders: { 'Content-Type': 'application/hal+json' } });
}
let token = this.tokenExtractor.getToken() as string;
if (token !== null) {
req = req.clone({ setHeaders: { 'X-XSRF-TOKEN': token } });
@ -36,9 +43,8 @@ export class HttpClientInterceptor implements HttpInterceptor {
(error) => {
this.requestInProgressService.setRequestInProgress(false);
if (
error.status !== 404 ||
!(error instanceof HttpErrorResponse) ||
error.url.indexOf('environment-information.json') === -1
error.status !== 404 &&
(!(error instanceof HttpErrorResponse) || error.url.indexOf('environment-information.json') === -1)
) {
const { key, messageVariables } = error.error.error || {
key: 'FALLBACK',

View File

@ -4,12 +4,11 @@ import { environment } from 'environments/environment';
import { UserInfo } from 'app/shared/models/user-info';
import { Version } from 'app/shared/models/version';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { map, shareReplay } from 'rxjs/operators';
@Injectable()
export class TaskanaEngineService {
currentUserInfo: UserInfo;
constructor(private httpClient: HttpClient) {}
// GET
@ -43,6 +42,10 @@ export class TaskanaEngineService {
return this.httpClient.get<boolean>(`${environment.taskanaRestUrl}/v1/history-provider-enabled`);
}
isCustomRoutingRulesEnabled$ = this.httpClient
.get<boolean>(`${environment.taskanaRestUrl}/v1/routing-rules/routing-rest-enabled`)
.pipe(shareReplay(1));
private findRole(roles2Find: string[]) {
return this.currentUserInfo.roles.find((role) => roles2Find.some((roleLookingFor) => role === roleLookingFor));
}

View File

@ -58,6 +58,7 @@ import { WorkbasketService } from 'app/shared/services/workbasket/workbasket.ser
import { ClassificationsService } from 'app/shared/services/classifications/classifications.service';
import { ObtainMessageService } from './services/obtain-message/obtain-message.service';
import { AccessIdsService } from './services/access-ids/access-ids.service';
import { DragAndDropDirective } from './directives/drag-and-drop.directive';
import { GermanTimeFormatPipe } from './pipes/german-time-format.pipe';
const MODULES = [
@ -101,6 +102,7 @@ const DECLARATIONS = [
DialogPopUpComponent,
WorkbasketFilterComponent,
TaskFilterComponent,
DragAndDropDirective,
GermanTimeFormatPipe
];

View File

@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { RoutingUploadService } from './routing-upload.service';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { StartupService } from '../shared/services/startup/startup.service';
import { TaskanaEngineService } from '../shared/services/taskana-engine/taskana-engine.service';
import { WindowRefService } from '../shared/services/window/window.service';
describe('RoutingUploadService', () => {
let service: RoutingUploadService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [StartupService, TaskanaEngineService, WindowRefService]
});
service = TestBed.inject(RoutingUploadService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,21 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { StartupService } from '../shared/services/startup/startup.service';
@Injectable({
providedIn: 'root'
})
export class RoutingUploadService {
constructor(private httpClient: HttpClient, private startupService: StartupService) {}
get url(): string {
return this.startupService.getTaskanaRestUrl() + '/v1/routing-rules/default/';
}
uploadRoutingRules(file: File) {
const formData = new FormData();
formData.append('excelRoutingFile', file);
const headers = new HttpHeaders().set('Content-Type', 'multipart/form-data');
return this.httpClient.put(this.url, formData, { headers });
}
}

View File

@ -0,0 +1,16 @@
<div class="routing-upload">
<h1>Upload custom routing rules</h1>
<div taskanaDragAndDrop (onFileDropped)="upload($event)" class="routing-upload__upload-area"
(click)="input.click()">
<svg class="routing-upload__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path></svg>
<span class="routing-upload__description" *ngIf="!file?.name">Click to choose file or drag and drop here</span>
<span class="routing-upload__description" *ngIf="file?.name">{{file?.name}}</span>
</div>
<input #input style="display: none;"
id="routingUpload"
type="file"
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
title="Upload task routing Excel File"
(change)="onFileChanged($event)">
</div>

View File

@ -0,0 +1,45 @@
@import 'src/theme/_colors.scss';
.fileover {
border: 4px solid $green;
}
.routing-upload {
width: 100%;
height: calc(100vh - 104px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: -68px;
> h1 {
margin-bottom: 48px;
}
&__upload-area {
height: 400px;
width: 400px;
padding: 16px;
background-color: $light-grey;
border: 4px solid $light-grey;
border-radius: 10px;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
&__upload-area:hover {
border: 4px solid $green;
}
&__icon {
height: 64px;
width: 64px;
color: $grey;
}
&__description {
margin-top: 16px;
color: $grey;
}
}

View File

@ -0,0 +1,32 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RoutingUploadComponent } from './routing-upload.component';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { StartupService } from '../../shared/services/startup/startup.service';
import { TaskanaEngineService } from '../../shared/services/taskana-engine/taskana-engine.service';
import { WindowRefService } from '../../shared/services/window/window.service';
import { MatDialogModule } from '@angular/material/dialog';
import { DragAndDropDirective } from '../../shared/directives/drag-and-drop.directive';
describe('RoutingUploadComponent', () => {
let component: RoutingUploadComponent;
let fixture: ComponentFixture<RoutingUploadComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [RoutingUploadComponent, DragAndDropDirective],
imports: [HttpClientTestingModule, MatDialogModule],
providers: [StartupService, TaskanaEngineService, WindowRefService]
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(RoutingUploadComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,48 @@
import { Component, OnInit } from '@angular/core';
import { RoutingUploadService } from '../routing-upload.service';
import { NotificationService } from '../../shared/services/notifications/notification.service';
import { HotToastService } from '@ngneat/hot-toast';
@Component({
selector: 'taskana-routing-upload',
templateUrl: './routing-upload.component.html',
styleUrls: ['./routing-upload.component.scss']
})
export class RoutingUploadComponent implements OnInit {
file: File | null = null;
constructor(
private routingUploadService: RoutingUploadService,
private toastService: HotToastService,
private notificationService: NotificationService
) {}
ngOnInit(): void {}
onFileChanged(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files?.length) return;
this.file = input.files[0];
this.upload(input.files);
}
upload(fileList?: FileList) {
if (typeof fileList !== 'undefined') {
this.file = fileList[0];
}
this.routingUploadService.uploadRoutingRules(this.file).subscribe({
next: (res: { amountOfImportedRow: number; result: string }) => this.toastService.success(res.result),
error: (err) => {
this.notificationService.showError(err.error.message.key);
this.clearInput();
},
complete: () => this.clearInput()
});
}
clearInput() {
const inputElement = document.getElementById('routingUpload') as HTMLInputElement;
inputElement.value = '';
this.file = null;
}
}

View File

@ -0,0 +1,18 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { RoutingUploadComponent } from './routing-upload/routing-upload.component';
import { TaskRoutingGuard } from '../shared/guards/task-routing.guard';
const routes: Routes = [
{
path: '',
canActivate: [TaskRoutingGuard],
component: RoutingUploadComponent
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class TaskRoutingRoutingModule {}

View File

@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TaskRoutingRoutingModule } from './task-routing-routing.module';
import { RoutingUploadComponent } from './routing-upload/routing-upload.component';
import { SharedModule } from '../shared/shared.module';
@NgModule({
declarations: [RoutingUploadComponent],
imports: [CommonModule, TaskRoutingRoutingModule, SharedModule]
})
export class TaskRoutingModule {}