task/827 Modify classification's parent

Create a drag and drop test
This commit is contained in:
Jose Ignacio Recuerda Cambil 2019-04-05 14:39:19 +02:00 committed by Holger Hagen
parent aea64975db
commit b09e0b789a
14 changed files with 770 additions and 879 deletions

1307
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -24,7 +24,7 @@
"@angular/router": "7.1.3",
"ajv": "6.5.2",
"angular-svg-icon": "6.0.0",
"angular-tree-component": "7.1.0",
"angular-tree-component": "8.2.0",
"bootstrap": "4.3.1",
"bootstrap-sass": "3.4.1",
"chart.js": "2.7.2",
@ -42,8 +42,8 @@
"zone.js": "0.8.26"
},
"devDependencies": {
"@angular-devkit/build-angular": "0.13.2",
"@angular/cli": "7.1.3",
"@angular-devkit/build-angular": "^0.13.8",
"@angular/cli": "^7.3.8",
"@angular/compiler-cli": "7.2.7",
"@types/jasmine": "2.8.4",
"@types/node": "9.3.0",

View File

@ -165,7 +165,7 @@ export class ClassificationDetailsComponent implements OnInit, OnDestroy {
this.action = undefined
}
private onSave() {
private async onSave() {
this.requestInProgressService.setRequestInProgress(true);
if (this.action === ACTION.CREATE) {
this.classificationSavingSubscription = this.classificationsService.postClassification(this.classification)
@ -180,17 +180,17 @@ export class ClassificationDetailsComponent implements OnInit, OnDestroy {
this.afterRequest();
});
} else {
this.classificationSavingSubscription = this.classificationsService
.putClassification(this.classification._links.self.href, this.classification)
.subscribe((classification: ClassificationDefinition) => {
this.classification = classification;
this.afterRequest();
this.alertService.triggerAlert(new AlertModel(AlertType.SUCCESS, `Classification ${classification.key} was saved successfully`));
this.cloneClassification(classification);
}, error => {
this.generalModalService.triggerMessage(new MessageModal('There was error while saving your classification', error))
this.afterRequest();
})
try {
this.classification = (<ClassificationDefinition> await this.classificationsService.putClassification(
this.classification._links.self.href, this.classification));
this.afterRequest();
this.alertService.triggerAlert(
new AlertModel(AlertType.SUCCESS, `Classification ${this.classification.key} was saved successfully`));
this.cloneClassification(this.classification);
} catch (error) {
this.generalModalService.triggerMessage(new MessageModal('There was error while saving your classification', error))
this.afterRequest();
}
}
}
@ -216,16 +216,15 @@ export class ClassificationDetailsComponent implements OnInit, OnDestroy {
this.classificationsService.triggerClassificationSaved();
}
private selectClassification(id: string) {
private async selectClassification(id: string) {
if (this.classificationIsAlreadySelected()) {
return true;
}
this.requestInProgress = true;
this.selectedClassificationSubscription = this.classificationsService.getClassification(id).subscribe(classification => {
this.fillClassificationInformation(classification)
this.classificationsService.selectClassification(classification);
this.requestInProgress = false;
});
const classification = await this.classificationsService.getClassification(id);
this.fillClassificationInformation(classification)
this.classificationsService.selectClassification(classification);
this.requestInProgress = false;
}
private classificationIsAlreadySelected(): boolean {
@ -285,7 +284,7 @@ export class ClassificationDetailsComponent implements OnInit, OnDestroy {
this.classification = undefined;
this.afterRequest();
this.classificationsService.selectClassification(undefined);
this.router.navigate(['administration/classifications']);
this.router.navigate(['taskana/administration/classifications']);
this.alertService.triggerAlert(new AlertModel(AlertType.SUCCESS, `Classification ${key} was removed successfully`))
}, error => {
this.generalModalService.triggerMessage(new MessageModal('There was error while removing your classification', error))

View File

@ -45,11 +45,12 @@
<taskana-spinner class="col-xs-12" [isRunning]="requestInProgress" positionClass="centered-spinner-whole-screen"></taskana-spinner>
<taskana-tree class="col-xs-12" *ngIf="(classifications && classifications.length) else empty_classifications"
[treeNodes]="classifications" [selectNodeId]="selectedId" [filterText]="inputValue" [filterIcon]="selectedCategory"
(selectNodeIdChanged)="selectClassification($event)"></taskana-tree>
(selectNodeIdChanged)="selectClassification($event)" (refreshClassification)="getClassifications($event)"
(switchTaskanaSpinnerEmit)="switchTaskanaSpinner($event)"></taskana-tree>
<ng-template #empty_classifications>
<div *ngIf="!requestInProgress" class="col-xs-12 container-no-items center-block">
<h3 class="grey">There are no classifications</h3>
<svg-icon class="img-responsive empty-icon" src="./assets/icons/classification-empty.svg"></svg-icon>
</div>
</ng-template>
</div>
</div>

View File

@ -13,6 +13,8 @@ import {
import { Pair } from 'app/models/pair';
import { ClassificationDefinition } from '../../../../models/classification-definition';
import { ImportExportService } from 'app/administration/services/import-export/import-export.service';
import {AlertModel, AlertType} from '../../../../models/alert';
import {AlertService} from '../../../../services/alert/alert.service';
@Component({
selector: 'taskana-classification-list',
@ -45,7 +47,8 @@ export class ClassificationListComponent implements OnInit, OnDestroy {
private router: Router,
private route: ActivatedRoute,
private categoryService: ClassificationCategoriesService,
private importExportService: ImportExportService) {
private importExportService: ImportExportService,
private alertService: AlertService) {
}
ngOnInit() {
@ -70,20 +73,15 @@ export class ClassificationListComponent implements OnInit, OnDestroy {
selectClassificationType(classificationTypeSelected: string) {
this.classifications = [];
this.requestInProgress = true;
this.categoryService.selectClassificationType(classificationTypeSelected);
this.classificationService.getClassifications()
.subscribe((classifications: Array<TreeNodeModel>) => {
this.classifications = classifications;
this.requestInProgress = false;
});
this.getClassifications();
this.selectClassification(undefined);
}
selectClassification(id: string) {
this.selectedId = id;
if (!id) {
this.router.navigate(['administration/classifications']);
this.router.navigate(['taskana/administration/classifications']);
return;
}
this.router.navigate([{ outlets: { detail: [this.selectedId] } }], { relativeTo: this.route });
@ -129,6 +127,24 @@ export class ClassificationListComponent implements OnInit, OnDestroy {
this.initialized = true;
}
private getClassifications(key: string = undefined) {
this.requestInProgress = true;
this.classificationService.getClassifications()
.subscribe((classifications: Array<TreeNodeModel>) => {
this.classifications = classifications;
this.requestInProgress = false;
});
if (key) {
this.alertService.triggerAlert(new AlertModel(AlertType.SUCCESS, `Classification ${key} was saved successfully`));
}
}
private switchTaskanaSpinner($event) {
this.requestInProgress = $event;
}
ngOnDestroy(): void {
if (this.classificationServiceSubscription) { this.classificationServiceSubscription.unsubscribe(); }
if (this.classificationTypeServiceSubscription) { this.classificationTypeServiceSubscription.unsubscribe(); }

View File

@ -288,7 +288,7 @@ export class WorkbasketInformationComponent
new AlertModel(AlertType.SUCCESS, 'The Workbasket ' + this.workbasket.workbasketId + ' has been marked for deletion')
);
}
this.router.navigate(['administration/workbaskets']);
this.router.navigate(['taskana/administration/workbaskets']);
}
);
}

View File

@ -8,14 +8,14 @@ import {WindowRefService} from '../window/window.service';
import {environment} from '../../../environments/environment';
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
fdescribe('StartupService', () => {
describe('StartupService', () => {
const environmentFile = '/environments/data-sources/environment-information.json';
const someRestUrl = 'someRestUrl';
const someLogoutUrl = 'someLogoutUrl';
const dummyEnvironmentInformation = {
'taskanaRestUrl': someRestUrl,
'taskanaLogoutUrl': someLogoutUrl
}
};
let httpMock, service;

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { Observable , Subject } from 'rxjs';
import { Subject } from 'rxjs';
@Injectable()
export class TreeService {

View File

@ -1,18 +1,18 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from 'environments/environment';
import { combineLatest, Observable, Subject} from 'rxjs';
import { mergeMap, tap } from 'rxjs/operators';
import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {environment} from 'environments/environment';
import {combineLatest, Observable, Subject} from 'rxjs';
import {mergeMap, tap} from 'rxjs/operators';
import { Classification } from 'app/models/classification';
import { ClassificationDefinition } from 'app/models/classification-definition';
import {Classification} from 'app/models/classification';
import {ClassificationDefinition} from 'app/models/classification-definition';
import { ClassificationResource } from 'app/models/classification-resource';
import { ClassificationCategoriesService } from './classification-categories.service';
import { DomainService } from 'app/services/domain/domain.service';
import { TaskanaQueryParameters } from 'app/shared/util/query-parameters';
import { Direction } from 'app/models/sorting';
import { QueryParametersModel } from 'app/models/query-parameters';
import {ClassificationResource} from 'app/models/classification-resource';
import {ClassificationCategoriesService} from './classification-categories.service';
import {DomainService} from 'app/services/domain/domain.service';
import {TaskanaQueryParameters} from 'app/shared/util/query-parameters';
import {Direction} from 'app/models/sorting';
import {QueryParametersModel} from 'app/models/query-parameters';
@Injectable()
export class ClassificationsService {
@ -37,7 +37,9 @@ export class ClassificationsService {
`${this.url}${TaskanaQueryParameters.getQueryParameters(this.classificationParameters(domain))}`));
}),
tap(() => { this.domainService.domainChangedComplete(); })
tap(() => {
this.domainService.domainChangedComplete();
})
)
}
@ -52,13 +54,13 @@ export class ClassificationsService {
}
// GET
getClassification(id: string): Observable<ClassificationDefinition> {
getClassification(id: string): Promise<ClassificationDefinition> {
return this.httpClient.get<ClassificationDefinition>(`${this.url}${id}`)
.pipe(tap((classification: ClassificationDefinition) => {
if (classification) {
this.classificationCategoriesService.selectClassificationType(classification.type);
}
}));
})).toPromise();
}
// POST
@ -67,8 +69,8 @@ export class ClassificationsService {
}
// PUT
putClassification(url: string, classification: Classification): Observable<Classification> {
return this.httpClient.put<Classification>(url, classification);
putClassification(url: string, classification: Classification): Promise<Classification> {
return this.httpClient.put<Classification>(url, classification).toPromise();
}
// DELETE

View File

@ -60,7 +60,7 @@ const MODULES = [
AngularSvgIconModule,
HttpClientModule,
RouterModule,
TreeModule
TreeModule.forRoot()
];
const DECLARATIONS = [

View File

@ -1,4 +1,5 @@
<tree-root #tree [nodes]="treeNodes" [options]="options" (activate)="onActivate($event)" (deactivate)="onDeactivate($event)">
<tree-root #tree [nodes]="treeNodes" [options]="options" (activate)="onActivate($event)" (deactivate)="onDeactivate($event)"
(moveNode)="onMoveNode($event)" (treeDrop)="onDrop($event)">
<ng-template #treeNodeTemplate let-node let-index="index">
<span class="text-top">
<svg-icon *ngIf="node.data.category" class="blue fa-fw" [src]="getCategoryIcon(node.data.category).name" data-toggle="tooltip"
@ -9,4 +10,4 @@
</span>
<span> - {{ node.data.name }}</span>
</ng-template>
</tree-root>
</tree-root>

View File

@ -1,16 +1,17 @@
import { Input, Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AngularSvgIconModule } from 'angular-svg-icon';
import { HttpClientModule } from '@angular/common/http';
import {Component, Input} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {AngularSvgIconModule} from 'angular-svg-icon';
import {HttpClientModule} from '@angular/common/http';
import { TaskanaTreeComponent } from './tree.component';
import {TaskanaTreeComponent} from './tree.component';
import { TreeService } from 'app/services/tree/tree.service';
import {
ClassificationCategoriesService
} from 'app/shared/services/classifications/classification-categories.service';
import { configureTests } from 'app/app.test.configuration';
import { Pair } from 'app/models/pair';
import {TreeService} from 'app/services/tree/tree.service';
import {configureTests} from 'app/app.test.configuration';
import {Pair} from 'app/models/pair';
import {ClassificationDefinition} from '../../models/classification-definition';
import {LinksClassification} from '../../models/links-classfication';
import {ClassificationCategoriesService} from '../services/classifications/classification-categories.service';
import {ClassificationsService} from '../services/classifications/classifications.service';
// tslint:disable:component-selector
@Component({
@ -22,23 +23,27 @@ class TreeVendorComponent {
@Input() state;
@Input() nodes;
treeModel = {
getActiveNode() { }
getActiveNode() {
}
}
}
// tslint:enable:component-selector
describe('TaskanaTreeComponent', () => {
let component: TaskanaTreeComponent;
let fixture: ComponentFixture<TaskanaTreeComponent>;
let classificationCategoriesService;
let classificationsService;
let moveNodeEvent;
let dropEvent;
beforeEach(done => {
const configure = (testBed: TestBed) => {
testBed.configureTestingModule({
imports: [AngularSvgIconModule, HttpClientModule],
declarations: [TreeVendorComponent],
providers: [TreeService, ClassificationCategoriesService]
providers: [TreeService, ClassificationCategoriesService, ClassificationsService]
})
};
@ -46,6 +51,45 @@ describe('TaskanaTreeComponent', () => {
fixture = testBed.createComponent(TaskanaTreeComponent);
classificationCategoriesService = testBed.get(ClassificationCategoriesService);
spyOn(classificationCategoriesService, 'getCategoryIcon').and.returnValue(new Pair('assets/icons/categories/external.svg'));
classificationsService = TestBed.get(ClassificationsService);
spyOn(classificationsService, 'putClassification').and.callFake(function (url, classification) {
return classification;
});
moveNodeEvent = {
eventName: 'moveNode',
node: {
classificationId: 'id4',
parentId: '',
parentKey: '',
_links: {
self: {
href: 'url'
}
}
},
to: {
parent: {
classificationId: 'id3',
key: 'key3'
}
}
};
dropEvent = {
event: {
target: {
tagName: 'TREE-VIEWPORT'
}
},
element: {
data: {
classificationId: 'id3',
parentId: 'id1',
parentKey: 'key1'
}
}
};
component = fixture.componentInstance;
fixture.detectChanges();
done();
@ -55,4 +99,41 @@ describe('TaskanaTreeComponent', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should be change the classification parent (onMoveNode)', async () => {
spyOn(classificationsService, 'getClassification').and.returnValue(new ClassificationDefinition('id4',
'key4', '', '', 'MANUAL', 'DOMAIN_A', 'TASK', true, '019-04-10T10:23:34.985Z', '2019-04-10T10:23:34.985Z',
'classification4', 'description', 1, 'level', '', '', '', '', '', ''
, '', '', '', new LinksClassification({href: ''}, '', '', {href: ''}, {href: ''}, {href: ''})));
spyOn(component, 'switchTaskanaSpinner');
const classification = classificationsService.getClassification();
expect(classification.parentId).toEqual('');
expect(classification.parentKey).toEqual('');
await component.onMoveNode(moveNodeEvent);
expect(classification.parentId).toEqual('id3');
expect(classification.parentKey).toEqual('key3');
expect(classificationsService.putClassification).toHaveBeenCalledWith(classification._links.self.href, classification);
expect(component.switchTaskanaSpinner).toHaveBeenCalledWith(true);
expect(component.switchTaskanaSpinner).toHaveBeenCalledWith(false);
});
it('should be changed the parent classification to root node (onDrop)', async () => {
spyOn(classificationsService, 'getClassification').and.returnValue(new ClassificationDefinition('id3',
'key3', 'id1', 'key1', 'MANUAL', 'DOMAIN_A', 'TASK', true, '019-04-10T10:23:34.985Z', '2019-04-10T10:23:34.985Z',
'classification3', 'description', 1, 'level', '', '', '', '', '', ''
, '', '', '', new LinksClassification({href: ''}, '', '', {href: ''}, {href: ''}, {href: ''})));
spyOn(component, 'switchTaskanaSpinner');
const classification = classificationsService.getClassification();
expect(classification.parentId).toEqual('id1');
expect(classification.parentKey).toEqual('key1');
await component.onDrop(dropEvent);
expect(classification.parentId).toEqual('');
expect(classification.parentKey).toEqual('');
expect(component.switchTaskanaSpinner).toHaveBeenCalledWith(true);
expect(component.switchTaskanaSpinner).toHaveBeenCalledWith(false);
});
});

View File

@ -1,16 +1,25 @@
import {
Component, OnInit, Input, Output, EventEmitter, ViewChild, AfterViewChecked,
OnDestroy, ElementRef, HostListener
AfterViewChecked,
Component,
ElementRef,
EventEmitter,
HostListener,
Input,
OnDestroy,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { TreeNodeModel } from 'app/models/tree-node';
import {TreeNodeModel} from 'app/models/tree-node';
import { KEYS, ITreeOptions, TreeComponent, TreeNode } from 'angular-tree-component';
import { TreeService } from '../../services/tree/tree.service';
import {
ClassificationCategoriesService
} from 'app/shared/services/classifications/classification-categories.service';
import { Pair } from 'app/models/pair';
import { Subscription } from 'rxjs';
import {ITreeOptions, KEYS, TreeComponent, TreeNode} from 'angular-tree-component';
import {TreeService} from '../../services/tree/tree.service';
import {ClassificationCategoriesService} from 'app/shared/services/classifications/classification-categories.service';
import {Pair} from 'app/models/pair';
import {Subscription} from 'rxjs';
import {Classification} from '../../models/classification';
import {ClassificationDefinition} from '../../models/classification-definition';
import {ClassificationsService} from '../services/classifications/classifications.service';
@Component({
selector: 'taskana-tree',
@ -19,19 +28,19 @@ import { Subscription } from 'rxjs';
})
export class TaskanaTreeComponent implements OnInit, AfterViewChecked, OnDestroy {
@ViewChild('tree')
private tree: TreeComponent;
@Input() treeNodes: TreeNodeModel;
@Input() treeNodes: Array<TreeNodeModel>;
@Output() treeNodesChange = new EventEmitter<Array<TreeNodeModel>>();
@Input() selectNodeId: string;
@Output() selectNodeIdChanged = new EventEmitter<string>();
@Input() filterText: string;
@Input() filterIcon = '';
@Output() refreshClassification = new EventEmitter<string>();
@Output() switchTaskanaSpinnerEmit = new EventEmitter<boolean>();
private filterTextOld: string
private filterTextOld: string;
private filterIconOld = '';
private removedNodeIdSubscription: Subscription;
@ -47,8 +56,10 @@ export class TaskanaTreeComponent implements OnInit, AfterViewChecked, OnDestroy
},
animateExpand: true,
animateSpeed: 20,
levelPadding: 20
}
levelPadding: 20,
allowDrag: true,
allowDrop: true
};
@HostListener('document:click', ['$event'])
onDocumentClick(event) {
@ -60,7 +71,9 @@ export class TaskanaTreeComponent implements OnInit, AfterViewChecked, OnDestroy
constructor(
private treeService: TreeService,
private categoryService: ClassificationCategoriesService,
private elementRef: ElementRef) { }
private elementRef: ElementRef,
private classificationsService: ClassificationsService) {
}
ngOnInit() {
this.removedNodeIdSubscription = this.treeService.getRemovedNodeId().subscribe(value => {
@ -71,7 +84,6 @@ export class TaskanaTreeComponent implements OnInit, AfterViewChecked, OnDestroy
});
}
ngAfterViewChecked(): void {
if (this.selectNodeId && !this.tree.treeModel.getActiveNode()) {
this.selectNode(this.selectNodeId);
@ -96,6 +108,25 @@ export class TaskanaTreeComponent implements OnInit, AfterViewChecked, OnDestroy
this.selectNodeIdChanged.emit(undefined);
}
async onMoveNode($event) {
this.switchTaskanaSpinner(true);
const classification = await this.getClassification($event.node.classificationId);
classification.parentId = $event.to.parent.classificationId;
classification.parentKey = $event.to.parent.key;
this.collapseParentNodeIfItIsTheLastChild($event.node);
await this.updateClassification(classification);
}
async onDrop($event) {
if ($event.event.target.tagName === 'TREE-VIEWPORT') {
this.switchTaskanaSpinner(true);
const classification = await this.getClassification($event.element.data.classificationId);
this.collapseParentNodeIfItIsTheLastChild($event.element.data);
classification.parentId = '';
classification.parentKey = '';
await this.updateClassification(classification);
}
}
getCategoryIcon(category: string): Pair {
return this.categoryService.getCategoryIcon(category);
@ -103,9 +134,9 @@ export class TaskanaTreeComponent implements OnInit, AfterViewChecked, OnDestroy
private selectNode(nodeId: string) {
if (nodeId) {
const selectedNode = this.getNode(nodeId)
const selectedNode = this.getNode(nodeId);
if (selectedNode) {
selectedNode.setIsActive(true)
selectedNode.setIsActive(true);
this.expandParent(selectedNode);
}
}
@ -141,6 +172,7 @@ export class TaskanaTreeComponent implements OnInit, AfterViewChecked, OnDestroy
return (node.data.name.toUpperCase().includes(text.toUpperCase())
|| node.data.key.toUpperCase().includes(text.toUpperCase()))
}
private checkIcon(node: any, iconText: string): boolean {
return (node.data.category.toUpperCase() === iconText.toUpperCase()
|| iconText === '')
@ -160,9 +192,31 @@ export class TaskanaTreeComponent implements OnInit, AfterViewChecked, OnDestroy
event.target.localName === 'taskana-tree')
}
private getClassification(classificationId: string): Promise<ClassificationDefinition> {
return this.classificationsService.getClassification(classificationId);
}
private async updateClassification(classification: Classification) {
await this.classificationsService.putClassification(classification._links.self.href, classification);
this.refreshClassification.emit(classification.key);
this.switchTaskanaSpinner(false);
}
private collapseParentNodeIfItIsTheLastChild(node: any) {
if (node.parentId.length > 0 && this.getNode(node.parentId) && this.getNode(node.parentId).children.length < 2) {
this.tree.treeModel.update();
this.getNode(node.parentId).collapse();
}
}
switchTaskanaSpinner(active: boolean) {
this.switchTaskanaSpinnerEmit.emit(active);
}
ngOnDestroy(): void {
if (this.removedNodeIdSubscription) { this.removedNodeIdSubscription.unsubscribe() }
if (this.removedNodeIdSubscription) {
this.removedNodeIdSubscription.unsubscribe()
}
}
}

View File

@ -46,7 +46,7 @@ export class TaskComponent implements OnInit, OnDestroy {
this.requestInProgress = true;
this.task = await this.taskService.getTask(id).toPromise();
const classification = await this.classificationService.getClassification
(this.task.classificationSummaryResource.classificationId).toPromise();
(this.task.classificationSummaryResource.classificationId);
this.address = this.extractUrl(classification.applicationEntryPoint) || `${this.address}/?q=${this.task.name}`;
this.link = this.sanitizer.bypassSecurityTrustResourceUrl(this.address);
this.getWorkbaskets();