TSK-1398: New material design for classification component (#1287)

* TSK-1394: Remove bootstrap 3

* TSK-1394: Temporarily remove cypress tests

* TSK-1398: fix classification component display

* TSK-1399: fix workbasket display

* TSK-1398: Fixed category selection in classification component

* TSK-1398: Fixed category selection in classification component
TSK-1398:

* TSK-1398: Added selectCategory method

* TSK-1398: Refactored Classification-Types-Selector

* TSK-1398: Replaced category dropdown in classification details by Select

* TSK-1398: Removed color of category icons

* TSK-1398: added new administration overview navbar

* TSK-1398: Removed bootstrap from action toolbar in classification-list

* TSK-1398: Removed bootstrap from import-export component

* TSK-1398: update visual in action bar in classification list and classification tree

* TSK-1398: Removed bootstrap classes from classification-list

* TSK-1398: update design

* TSK-1398: update classification list design

* TSK-1398: Refactored action-toolbar in classification-details

* TSK-1398: rework administration routing configuration, now nested in mat-tabs

* TSK-1398: administration tabs displays correctly when refreshing/first time open

* TSK-1398: fixed overall CSS errors

* TSK-1398: design update, added tooltips to buttons

* TSK-1398: minor visual changes

* TSK-1398: Refactored classification-details

* TSK-1398: Fixed category selector in classification-details

* TSK-1398: Modified category-icon css

* TSK-1398: update classification details actionbar design

* TSK-1398: minor design update, correctly highlight tab when details is showing

* TSK-1398: Fixed tests in classification-details

* TSK-1398: fixed "valid in domain" text not showing

* TSK-1398: add domain selector in administration overview tabs

* TSK-1398: Fixed tests in classification-list

* TSK-1398: added unit tests for admin overview

* TSK-1398: Update design of action bar in classification list

* TSK-1398: Fixed tests

* TSK-1398: minor CSS, tooltips update

* TSK-1398: fix broken test in classification list

Co-authored-by: Sofie Hofmann <29145005+sofie29@users.noreply.github.com>
This commit is contained in:
Chi Nguyen 2020-10-06 09:58:30 +02:00 committed by GitHub
parent 34f9793907
commit 6a8311a32b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 932 additions and 464 deletions

View File

@ -5,69 +5,77 @@ import { DomainGuard } from 'app/shared/guards/domain.guard';
import { AccessItemsManagementComponent } from './components/access-items-management/access-items-management.component';
import { ClassificationOverviewComponent } from './components/classification-overview/classification-overview.component';
import { WorkbasketOverviewComponent } from './components/workbasket-overview/workbasket-overview.component';
import { AdministrationOverviewComponent } from './components/administration-overview/administration-overview.component';
const routes: Routes = [
{
path: 'workbaskets',
component: WorkbasketOverviewComponent,
path: '',
component: AdministrationOverviewComponent,
canActivate: [DomainGuard],
children: [
{
path: '',
path: 'workbaskets',
component: WorkbasketOverviewComponent,
outlet: 'master'
canActivate: [DomainGuard],
children: [
{
path: '',
component: WorkbasketOverviewComponent,
outlet: 'master'
},
{
path: ':id',
component: WorkbasketOverviewComponent,
outlet: 'detail'
},
{
path: '**',
redirectTo: ''
}
]
},
{
path: ':id',
component: WorkbasketOverviewComponent,
outlet: 'detail'
},
{
path: '**',
redirectTo: ''
}
]
},
{
path: 'classifications',
component: ClassificationOverviewComponent,
canActivate: [DomainGuard],
children: [
{
path: '',
path: 'classifications',
component: ClassificationOverviewComponent,
outlet: 'master'
canActivate: [DomainGuard],
children: [
{
path: '',
component: ClassificationOverviewComponent,
outlet: 'master'
},
{
path: ':id',
component: ClassificationOverviewComponent,
outlet: 'detail'
},
{
path: '**',
redirectTo: ''
}
]
},
{
path: ':id',
component: ClassificationOverviewComponent,
outlet: 'detail'
},
{
path: '**',
redirectTo: ''
}
]
},
{
path: 'access-items-management',
component: AccessItemsManagementComponent,
canActivate: [DomainGuard],
children: [
{
path: '**',
redirectTo: ''
path: 'access-items-management',
component: AccessItemsManagementComponent,
canActivate: [DomainGuard],
children: [
{
path: '**',
redirectTo: ''
}
]
}
]
},
{
path: '',
redirectTo: 'workbaskets',
redirectTo: '',
pathMatch: 'full'
},
{
path: '**',
redirectTo: 'workbaskets'
redirectTo: ''
}
];

View File

@ -34,6 +34,16 @@ import { WorkbasketDefinitionService } from './services/workbasket-definition.se
import { ImportExportService } from './services/import-export.service';
import { ClassificationOverviewComponent } from './components/classification-overview/classification-overview.component';
import { WorkbasketOverviewComponent } from './components/workbasket-overview/workbasket-overview.component';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatMenuModule } from '@angular/material/menu';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs';
import { AdministrationOverviewComponent } from './components/administration-overview/administration-overview.component';
import { MatInputModule } from '@angular/material/input';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDividerModule } from '@angular/material/divider';
const MODULES = [
CommonModule,
@ -61,12 +71,25 @@ const DECLARATIONS = [
ClassificationTypesSelectorComponent,
ClassificationDetailsComponent,
ImportExportComponent,
AccessItemsManagementComponent
AccessItemsManagementComponent,
AdministrationOverviewComponent
];
@NgModule({
declarations: DECLARATIONS,
imports: [MODULES, MatRadioModule],
imports: [
MODULES,
MatRadioModule,
MatFormFieldModule,
MatSelectModule,
MatMenuModule,
MatIconModule,
MatButtonModule,
MatTabsModule,
MatInputModule,
MatTooltipModule,
MatDividerModule
],
providers: [
ClassificationDefinitionService,
WorkbasketDefinitionService,

View File

@ -1,4 +1,4 @@
<div class="panel panel-default">
<div class="access-items-management panel panel-default">
<div class="panel-heading">
<h4 class="panel-header">Access items management</h4>

View File

@ -1,5 +1,9 @@
@import '../../../../theme/colors';
.access-items-management {
min-width: 100%;
}
.margin {
margin-top: 10px;
margin-bottom: 20px;

View File

@ -0,0 +1,21 @@
<div class="administration-overview">
<nav mat-tab-nav-bar class="administration-overview__navbar" backgroundColor="#f5f5f5" >
<a mat-tab-link class="administration-overview__navbar-links" routerLink="/taskana/administration/workbaskets" [active]="selectedTab == 'workbaskets'"
(click)="selectedTab = 'workbaskets'">Workbaskets</a>
<a mat-tab-link class="administration-overview__navbar-links" routerLink="/taskana/administration/classifications" [active]="selectedTab == 'classifications'"
(click)="selectedTab = 'classifications'">Classifications</a>
<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>
</nav>
<div class="administration-overview__domain">
<mat-form-field appearance="legacy">
<mat-select [value]="selectedDomain" matTooltip="Select domain">
<mat-option *ngFor="let domain of domains" [value]="domain" (click)="switchDomain(domain)">
{{domain? domain: 'MASTER DOMAIN'}}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<router-outlet></router-outlet>
</div>

View File

@ -0,0 +1,27 @@
@import 'src/theme/_colors.scss';
.administration-overview__navbar {
position: relative;
}
.administration-overview__navbar {
background-color: $light-grey;
}
.administration-overview__domain {
max-height: 48px;
position: absolute;
top: 46px;
right: 24px;
z-index: 11;
}
.mat-tab-link {
color: #1a202c !important;
}
.mat-tab-label-active {
font-weight: bolder !important;
color: unset;
opacity: 1;
}

View File

@ -0,0 +1,61 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AdministrationOverviewComponent } from './administration-overview.component';
import { DebugElement } from '@angular/core';
import { MatSelectModule } from '@angular/material/select';
import { MatTabsModule } from '@angular/material/tabs';
import { RouterTestingModule } from '@angular/router/testing';
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';
const domainServiceSpy = jest.fn().mockImplementation(
(): Partial<DomainService> => ({
getDomains: jest.fn().mockReturnValue(of(['domain a', 'domain b'])),
getSelectedDomain: jest.fn().mockReturnValue(of('domain a')),
switchDomain: jest.fn()
})
);
describe('AdministrationOverviewComponent', () => {
let component: AdministrationOverviewComponent;
let fixture: ComponentFixture<AdministrationOverviewComponent>;
let debugElement: DebugElement;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
MatSelectModule,
MatTabsModule,
RouterTestingModule.withRoutes([]),
HttpClientTestingModule,
BrowserAnimationsModule
],
declarations: [AdministrationOverviewComponent],
providers: [{ provide: DomainService, useClass: domainServiceSpy }]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AdministrationOverviewComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create component', () => {
expect(component).toBeTruthy();
});
it('should render 3 tabs in navbar', () => {
const navbar = fixture.debugElement.nativeElement.getElementsByClassName('administration-overview__navbar-links');
expect(navbar).toHaveLength(3);
});
it('should display current domain', () => {
const domainElem = fixture.debugElement.nativeElement.querySelector('.administration-overview__domain');
expect(domainElem).toBeTruthy();
fixture.detectChanges();
expect(domainElem.textContent).toMatch('domain a');
});
});

View File

@ -0,0 +1,52 @@
import { Component, Input, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Subject } from 'rxjs';
import { DomainService } from '../../../shared/services/domain/domain.service';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'taskana-administration-overview',
templateUrl: './administration-overview.component.html',
styleUrls: ['./administration-overview.component.scss']
})
export class AdministrationOverviewComponent implements OnInit {
@Input() selectedTab = '';
domains: Array<string> = [];
selectedDomain: string;
destroy$ = new Subject<void>();
constructor(private router: Router, private domainService: DomainService) {}
ngOnInit() {
this.domainService
.getDomains()
.pipe(takeUntil(this.destroy$))
.subscribe((domains) => {
this.domains = domains;
});
this.domainService
.getSelectedDomain()
.pipe(takeUntil(this.destroy$))
.subscribe((domain) => {
this.selectedDomain = domain;
});
const urlPaths = this.router.url.split('/');
if (this.router.url.includes('detail')) {
this.selectedTab = urlPaths[urlPaths.length - 2];
} else {
this.selectedTab = urlPaths[urlPaths.length - 1];
}
}
switchDomain(domain) {
this.domainService.switchDomain(domain);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -1,189 +1,209 @@
<div class="container-scrollable">
<div class="classification-details">
<taskana-shared-spinner [isRunning]="requestInProgress" class="floating"
(spinnerIsRunning)="spinnerRunning($event)"></taskana-shared-spinner>
<div id="classification-details" *ngIf="classification && !spinnerIsRunning">
<div id="classification" class="panel panel-default classification">
<div class="classification-details__wrapper" id="classification-details" *ngIf="classification && !spinnerIsRunning">
<!-- TITLE + ACTION BUTTONS -->
<div class="panel-heading">
<div class="pull-right btn-group classification__menu-bar">
<button type="button" (click)="onSubmit()" class="btn btn-default btn-primary"
data-toggle="tooltip" title="Save">
<span class="material-icons md-20">save</span>
</button>
<button type="button" (click)="onRestore()" class="btn btn-default" data-toggle="tooltip"
title="Restore Previous Version">
<span class="material-icons md-20 blue">restore</span>
</button>
<button type="button" (click)="onCopy()" data-toggle="tooltip" title="Copy"
class="btn btn-default" id="copyButton">
<span class="material-icons md-20 green-blue">content_copy</span>
</button>
<button type="button" (click)="onRemoveClassification()" data-toggle="tooltip"
title="Delete" class="btn btn-default">
<span class="material-icons md-20 red">delete</span>
</button>
</div>
<h4 class="panel-header classification__headline">{{classification.name}}&nbsp; [{{classification.type}}]
<span *ngIf="isCreatingNewClassification" class="badge warning"> {{badgeMessage$ | async}}</span>
</h4>
<!-- TITLE + ACTION BUTTONS -->
<section class="classification-details__action-toolbar">
<h4 class="classification-details__headline">{{classification.name}}&nbsp; [{{classification.type}}]
<span *ngIf="isCreatingNewClassification" class="badge warning"> {{badgeMessage$ | async}}</span>
</h4>
<div>
<button mat-button class="action-toolbar__button action-toolbar__save-button" matTooltip="Save changes in current classification" (click)="onSubmit()">
Save
<mat-icon class="md-20">save</mat-icon>
</button>
<button mat-stroked-button class="action-toolbar__button" matTooltip="Revert changes to previous saved state" (click)="onRestore()">
Undo Changes
<mat-icon class="button__green-blue md-20">restore</mat-icon>
</button>
<button mat-stroked-button [matMenuTriggerFor]="buttonMenu" matTooltip="More actions" class="action-toolbar__button" id="action-toolbar__more-buttons">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #buttonMenu="matMenu">
<button mat-menu-item class="action-toolbar__dropdown" matTooltip="Copy current values to create new classification" (click)="onCopy()">
<mat-icon class="button__green-blue">content_copy</mat-icon>
<span>Copy</span>
</button>
<button mat-menu-item class="action-toolbar__dropdown" matTooltip="Delete this classification" (click)="onRemoveClassification()">
<mat-icon class="button__red">delete</mat-icon>
<span>Delete</span>
</button>
<button mat-menu-item class="action-toolbar__dropdown" style="border-bottom-style: none;" matTooltip="Close this classification and discard all changes" (click)="onCloseClassification()">
<mat-icon>close</mat-icon>
<span>Close</span>
</button>
</mat-menu>
</div>
<!-- DETAILED FIELDS -->
<div class="panel-body classification__detailed-fields" style="padding: 0">
<ng-form #ClassificationForm="ngForm">
<div class="row" style="padding: 15px">
<div class="col-md-6">
</section>
<!--TODO pattern?-->
<!-- KEY -->
<div class="form-group required">
<label for="classification-key" class="control-label">Key</label>
<input type="text" required maxlength="32" #key="ngModel" [disabled]="!isCreatingNewClassification"
class="form-control"
id="classification-key" placeholder="Key" [(ngModel)]="classification.key"
name="classification.key" (input)="validateInputOverflow(key, 32)">
<div *ngIf="inputOverflowMap.get(key.name)" class="error">{{lengthError}}</div>
<taskana-shared-field-error-display [displayError]="key.invalid && key.dirty"
errorMessage="* Key is required">
</taskana-shared-field-error-display>
</div>
<!-- DETAILED FIELDS -->
<div class="panel-body" style="padding: 0">
<ng-form #ClassificationForm="ngForm">
<div class="classification__detailed-fields">
<!-- NAME -->
<div class="form-group required">
<label for="classification-name" class="control-label">Name</label>
<input type="text" required maxlength="255" #name="ngModel"
class="form-control"
id="classification-name" placeholder="Name"
[(ngModel)]="classification.name" name="classification.name"
(input)="validateInputOverflow(name, 255)">
<div *ngIf="inputOverflowMap.get(name.name)" class="error">{{lengthError}}</div>
<taskana-shared-field-error-display [displayError]="name.invalid && name.dirty"
errorMessage="* Name is required">
</taskana-shared-field-error-display>
</div>
<h6 class="classification-details__subheading" style="margin-top: 65px;"> General </h6>
<mat-divider class="classification-details__horizontal-line"> </mat-divider>
<!-- DOMAIN -->
<div class="form-group detailed-fields__domain">
<label for="classification-domain" class="control-label">Domain</label>
<input type="text" disabled #domain class="form-control" id="classification-domain"
placeholder="Domain" [(ngModel)]="classification.domain"
name="classification.domain">
<a *ngIf="!masterDomainSelected()" (click)="validChanged()">
<label>
<b>Valid in Domain:</b>
<span class="material-icons md-20 blue ">{{classification.isValidInDomain
? 'check_box' :
'check_box_outline_blank'}}</span>
</label>
</a>
</div>
<mat-form-field appearance="outline">
<mat-label>Key</mat-label>
<label for="classification-key"></label>
<input matInput required type="text" #key="ngModel" maxlength="32" [disabled]="!isCreatingNewClassification"
id="classification-key" placeholder="Key" [(ngModel)]="classification.key"
name="classification.key" (input)="validateInputOverflow(key, 32)">
</mat-form-field>
<div *ngIf="inputOverflowMap.get(key.name)" class="error">{{lengthError}}</div>
<!-- <taskana-shared-field-error-display [displayError]="key.invalid && key.dirty"
errorMessage="* Key is required">
</taskana-shared-field-error-display> -->
<!-- PRIORITY AND CATEGORY -->
<div class="row">
<div class="form-group required col-xs-6">
<label for="classification-priority" class="control-label">Priority</label>
<taskana-shared-number-picker [(ngModel)]="classification.priority"
name="classification.priority"
id="classification-priority"
[required]="true"></taskana-shared-number-picker>
<taskana-shared-field-error-display
[displayError]="!isFieldValid('classification.priority')"
[validationTrigger]="this.toggleValidationMap.get('classification.priority.name')"
errorMessage="* Priority is required">
</taskana-shared-field-error-display>
</div>
<div class="form-group required btn-group col-xs-6">
<label for="classification-category" class="control-label">Category</label>
<div class="dropdown clearfix">
<button class="btn btn-default" type="button" data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="true" id="classification-category">
<span class="text-top">
<svg-icon class="blue fa-fw"
src="{{(getCategoryIcon(classification.category) | async)?.name}}"
data-toggle="tooltip"
[title]="(getCategoryIcon(classification.category) | async)?.text"></svg-icon>
</span>
{{classification.category}}
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu" aria-labelledby="dropdownMenu">
<li>
<a class="detailed-fields__categories" *ngFor="let category of getAvailableCategories(classification.type) | async"
(click)="selectCategory(category)">
<span class="text-top">
<svg-icon class="blue fa-fw"
src="{{(getCategoryIcon(category) | async)?.name}}"
data-toggle="tooltip"
[title]="(getCategoryIcon(category) | async)?.text"></svg-icon>
{{category}}
</span>
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<!-- NAME -->
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<label for="classification-name"></label>
<input matInput type="text" required maxlength="255" #name="ngModel"
id="classification-name" placeholder="Name"
[(ngModel)]="classification.name" name="classification.name"
(input)="validateInputOverflow(name, 255)">
</mat-form-field>
<div *ngIf="inputOverflowMap.get(name.name)" class="error">{{lengthError}}</div>
<!--
<taskana-shared-field-error-display [displayError]="name.invalid && name.dirty"
errorMessage="* Name is required">
</taskana-shared-field-error-display> -->
<!-- SERVICE LEVEL -->
<div class="form-group">
<label for="classification-service-level" class="control-label">Service
Level</label>
<input type="text" maxlength="255" class="form-control"
id="classification-service-level" placeholder="Service Level"
[(ngModel)]="classification.serviceLevel" name="classification.serviceLevel"
#serviceLevel="ngModel" (input)="validateInputOverflow(serviceLevel, 255)">
<div *ngIf="inputOverflowMap.get(serviceLevel.name)" class="error">{{lengthError}}</div>
</div>
<!-- APPLICATION ENTRY POINT -->
<div class="form-group">
<label for="classification-application-entry-point" class="control-label">
Application entry point</label>
<input type="text" maxlength="255" class="form-control"
id="classification-application-entry-point"
placeholder="Application entry point"
[(ngModel)]="classification.applicationEntryPoint"
name="classification.applicationEntryPoint" #appEntryPoint="ngModel"
(input)="validateInputOverflow(appEntryPoint, 255)">
<div *ngIf="inputOverflowMap.get(appEntryPoint.name)" class="error">{{lengthError}}</div>
</div>
<!-- SERVICE LEVEL AND PRIORITY-->
<div class="classification-details__service-and-priority">
<mat-form-field appearance="outline" class="classification-details__mat-form-field">
<mat-label> Service Level </mat-label>
<label for="classification-service-level"></label>
<input matInput type="text" required maxlength="255"
id="classification-service-level" placeholder="Service Level"
[(ngModel)]="classification.serviceLevel" name="classification.serviceLevel"
#serviceLevel="ngModel" (input)="validateInputOverflow(serviceLevel, 255)">
</mat-form-field>
<div *ngIf="inputOverflowMap.get(serviceLevel.name)" class="error">{{lengthError}}</div>
<div>
<!-- I replaced this component by the mat-form-field. Is this the same?
I don't understand all methods in the number-picker component.
<taskana-shared-number-picker [(ngModel)]="classification.priority"
name="classification.priority"
id="classification-priority"
[required]="true"
[name]="'Priority'">
</taskana-shared-number-picker> -->
<mat-form-field appearance="outline">
<mat-label>Priority</mat-label>
<label for="classification-priority"></label>
<input matInput type="number" [(ngModel)]="classification.priority"
name="classification.priority" id="classification-priority" required min="0">
</mat-form-field>
<taskana-shared-field-error-display
[displayError]="!isFieldValid('classification.priority')"
[validationTrigger]="this.toggleValidationMap.get('classification.priority.name')"
errorMessage="* Priority is required">
</taskana-shared-field-error-display>
<!-- DESCRIPTION -->
<div class="form-group">
<label for="classification-description" class="control-label">Description</label>
<textarea class="form-control" maxlength="255" rows="5"
id="classification-description" placeholder="Description"
[(ngModel)]="classification.description"
name="classification.description" #description="ngModel"
(input)="validateInputOverflow(description, 255)"></textarea>
<div *ngIf="inputOverflowMap.get(description.name)" class="error">{{lengthError}}</div>
</div>
</div>
</div>
<!-- DOMAIN AND CATEGORY -->
<div class="classification-details__domain-and-category">
<mat-form-field class="classification-details__mat-form-field" appearance="outline">
<mat-label>Domain</mat-label>
<label for="classification-domain"></label>
<input matInput type="text" disabled id="classification-domain"
placeholder="Domain" [(ngModel)]="classification.domain"
name="classification.domain">
</mat-form-field>
<div class="domain-and-category__domain-checkbox">
Valid in Domain
<a *ngIf="!masterDomainSelected()" (click)="validChanged()" title="Valid in Domain">
<mat-icon class="domain-and-category__domain-checkbox-icon">{{classification.isValidInDomain ? 'check_box' : 'check_box_outline_blank'}}</mat-icon>
</a>
</div>
<mat-form-field appearance="outline">
<mat-label>Category</mat-label>
<mat-select required [(value)]="this.classification.category">
<mat-select-trigger>
<svg-icon
class="domain-and-category__category-icon" [src]="(getCategoryIcon(this.classification.category) | async)?.name">
</svg-icon>
{{this.classification.category}}
</mat-select-trigger>
<mat-option *ngFor="let category of categories$ | async" value="{{category}}">
<svg-icon class="domain-and-category__category-icon" [src]="(getCategoryIcon(category) | async)?.name"></svg-icon>
{{category}}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<!-- APPLICATION ENTRY POINT -->
<mat-form-field appearance="outline">
<mat-label>Application entry point</mat-label>
<label for="classification-application-entry-point"></label>
<input matInput type="text" maxlength="255"
id="classification-application-entry-point"
placeholder="Application entry point"
[(ngModel)]="classification.applicationEntryPoint"
name="classification.applicationEntryPoint" #appEntryPoint="ngModel"
(input)="validateInputOverflow(appEntryPoint, 255)">
</mat-form-field>
<div *ngIf="inputOverflowMap.get(appEntryPoint.name)" class="error">{{lengthError}}</div>
<!-- DESCRIPTION -->
<mat-form-field appearance="outline">
<mat-label>Description</mat-label>
<label for="classification-description"></label>
<textarea matInput
cdkTextareaAutosize
cdkAutosizeMinRows="1"
cdkAutosizeMaxRows="5"
maxlength="255"
id="classification-description" placeholder="Description"
[(ngModel)]="classification.description"
name="classification.description" #description="ngModel"
(input)="validateInputOverflow(description, 255)"></textarea>
</mat-form-field>
<div *ngIf="inputOverflowMap.get(description.name)" class="error">{{lengthError}}</div>
<!-- CUSTOM FIELDS -->
<div class="row custom-field-row">
<div class="custom-classification-form"
*ngFor="let customField of (customFields$ | async), let i = index"
style="width: 50%;">
<div *ngIf="customField.visible" class="form-group custom-field-wrapper">
<label for="classification-custom-{{i + 1}}"
class="control-label">{{customField.field}}</label>
<input type="text" maxlength="255" class="form-control"
id="classification-custom-{{i + 1}}" placeholder="{{customField.field}}"
[(ngModel)]="classification[getClassificationCustom(i + 1)]"
name="classification.custom{{i + 1}}" #custom="ngModel"
(input)="validateInputOverflow(custom, 255)">
<div *ngIf="inputOverflowMap.get(custom.name)" class="error">{{lengthError}}</div>
</div>
<h6 class="classification-details__subheading" style="padding-top: 50px"> Custom Fields </h6>
<mat-divider class="classification-details__horizontal-line"> </mat-divider>
<div *ngFor="let customField of (customFields$ | async), let i = index" >
<div *ngIf="customField.visible">
<mat-form-field appearance="outline" style="width: 100%">
<mat-label>{{customField.field}}</mat-label>
<label for="classification-custom-{{i + 1}}"></label>
<input matInput type="text" maxlength="255"
id="classification-custom-{{i + 1}}" placeholder="{{customField.field}}"
[(ngModel)]="classification[getClassificationCustom(i + 1)]"
name="classification.custom{{i + 1}}" #custom="ngModel"
(input)="validateInputOverflow(custom, 255)">
</mat-form-field>
<div *ngIf="inputOverflowMap.get(custom.name)" class="error">{{lengthError}}</div>
</div>
</div>
</ng-form>
</div>
</div>
</ng-form>
</div>
</div>
</div>

View File

@ -1,22 +1,114 @@
@import 'src/theme/_colors.scss';
.custom-field-row {
display: flex;
flex-wrap: wrap;
flex-direction: column;
height: 40vh;
.classification-details {
width: 100%;
margin: 0;
height: calc(100vh - 100px);
overflow-y: auto;
}
.custom-field-wrapper {
height: 70px;
padding: 0 15px;
.classification-details__wrapper {
position: relative;
}
.dropdown-menu > li {
/* ACTION TOOLBAR */
.classification-details__headline {
padding-top: 0.5rem;
}
.classification-details__action-toolbar {
width: calc(100% - 450px);
position: fixed;
padding: 12px 32px 12px 24px;
background-color: #fff;
display: flex;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
justify-content: space-between;
flex-wrap: wrap;
z-index: 10;
}
.action-toolbar__button {
margin-top: 0.25rem;
margin-right: 6px;
background-color: #fff;
}
.action-toolbar__dropdown {
border-color: $transparent-grey;
border-bottom-style: solid;
border-width: 1px;
}
.action-toolbar__save-button {
background-color: $aquamarine;
color: white;
}
.button__green-blue {
color: $aquamarine;
}
.button__red {
color: $invalid;
}
/* DETAILED FIELDS */
.classification__detailed-fields {
padding: 15px;
display: flex;
flex-direction: column;
}
.classification-details__subheading {
font-weight: bold;
padding-left: 15px;
margin-bottom: 0;
}
.classification-details__horizontal-line {
margin: 5px 5px 25px 5px;
border-top-color: #555;
border-top-width: 1.35px;
}
.classification-details__domain-and-category {
position: relative;
display: flex;
justify-content: space-between;
padding-bottom: 16px;
}
.domain-and-category__domain-checkbox {
position: absolute;
top: 64px;
left: 12px;
font-size: 14px;
color: #555;
}
.domain-and-category__domain-checkbox-icon {
cursor: pointer;
margin-top: 2px;
font-size: 20px;
}
.domain-and-category__category-icon {
fill: #555;
margin-right: 5px;
top: -2px;
}
.classification-details__service-and-priority {
display: flex;
justify-content: space-between;
}
.classification-details__mat-form-field {
width: 70%;
margin-right: 10px;
}
input:invalid.dirty {
border-color: $invalid;
}

View File

@ -4,7 +4,7 @@ import { Observable, of } from 'rxjs';
import { ClassificationCategoriesService } from '../../../shared/services/classification-categories/classification-categories.service';
import { DomainService } from '../../../shared/services/domain/domain.service';
import { ImportExportService } from '../../services/import-export.service';
import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Actions, NgxsModule, ofActionDispatched, Store } from '@ngxs/store';
import { ClassificationState } from '../../../shared/store/classification-store/classification.state';
import { EngineConfigurationState } from '../../../shared/store/engine-configuration-store/engine-configuration.state';
@ -13,9 +13,6 @@ import { ClassificationDetailsComponent } from './classification-details.compone
import { FormsModule } from '@angular/forms';
import { RequestInProgressService } from '../../../shared/services/request-in-progress/request-in-progress.service';
import { FormsValidatorService } from '../../../shared/services/forms-validator/forms-validator.service';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialogModule } from '@angular/material/dialog';
import { NumberPickerComponent } from '../../../shared/components/number-picker/number-picker.component';
import { NotificationService } from '../../../shared/services/notifications/notification.service';
import {
CopyClassification,
@ -24,6 +21,15 @@ import {
SaveCreatedClassification,
SaveModifiedClassification
} from '../../../shared/store/classification-store/classification.actions';
import { MatIconModule } from '@angular/material/icon';
import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatOptionModule } from '@angular/material/core';
import { MatSelectModule } from '@angular/material/select';
import { MatMenuModule } from '@angular/material/menu';
import { MatInputModule } from '@angular/material/input';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { By } from '@angular/platform-browser';
@Component({ selector: 'taskana-shared-spinner', template: '' })
class SpinnerStub {
@ -116,15 +122,20 @@ describe('ClassificationDetailsComponent', () => {
imports: [
NgxsModule.forRoot([ClassificationState, EngineConfigurationState]),
FormsModule,
MatSnackBarModule,
MatDialogModule
MatIconModule,
MatDividerModule,
MatFormFieldModule,
MatInputModule,
MatOptionModule,
MatSelectModule,
MatMenuModule,
BrowserAnimationsModule
],
declarations: [
ClassificationDetailsComponent,
SpinnerStub,
InputStub,
FieldErrorDisplayStub,
NumberPickerComponent,
SvgIconStub,
TextareaStub
],
@ -247,7 +258,7 @@ describe('ClassificationDetailsComponent', () => {
component.spinnerIsRunning = true;
component.classification = {};
fixture.detectChanges();
expect(debugElement.nativeElement.querySelector('.classification__menu-bar')).toBeFalsy();
expect(debugElement.nativeElement.querySelector('.classification-details__action-toolbar')).toBeFalsy();
expect(debugElement.nativeElement.querySelector('.classification__detailed-fields')).toBeFalsy();
});
@ -255,12 +266,12 @@ describe('ClassificationDetailsComponent', () => {
component.spinnerIsRunning = false;
component.classification = null;
fixture.detectChanges();
expect(debugElement.nativeElement.querySelector('.classification__menu-bar')).toBeFalsy();
expect(debugElement.nativeElement.querySelector('.classification-details__action-toolbar')).toBeFalsy();
expect(debugElement.nativeElement.querySelector('.classification__detailed-fields')).toBeFalsy();
});
it('should show details when classification exists and spinner is not running', () => {
expect(debugElement.nativeElement.querySelector('.classification__menu-bar')).toBeTruthy();
expect(debugElement.nativeElement.querySelector('.classification-details__action-toolbar')).toBeTruthy();
expect(debugElement.nativeElement.querySelector('.classification__detailed-fields')).toBeTruthy();
});
@ -269,7 +280,7 @@ describe('ClassificationDetailsComponent', () => {
component.classification = { name: 'Recommendation', type: 'DOCUMENT' };
component.isCreatingNewClassification = true;
fixture.detectChanges();
const headline = debugElement.nativeElement.querySelector('.classification__headline');
const headline = debugElement.nativeElement.querySelector('.classification-details__headline');
expect(headline).toBeTruthy();
expect(headline.textContent).toContain('Recommendation');
expect(headline.textContent).toContain('DOCUMENT');
@ -278,19 +289,22 @@ describe('ClassificationDetailsComponent', () => {
expect(badgeMessage.textContent.trim()).toBe('Creating new classification');
});
it('should call onSubmit() when button is clicked', () => {
const button = debugElement.nativeElement.querySelector('.classification__menu-bar').children[0];
it('should call onSubmit() when button is clicked', async () => {
const button = debugElement.nativeElement.querySelector('.action-toolbar__save-button');
expect(button).toBeTruthy();
expect(button.title).toBe('Save');
expect(button.textContent).toContain('Save');
expect(button.textContent).toContain('save');
component.onSubmit = jest.fn().mockImplementation();
button.click();
expect(component.onSubmit).toHaveBeenCalled();
});
it('should restore selected classification when button is clicked', async () => {
const button = debugElement.nativeElement.querySelector('.classification__menu-bar').children[1];
const button = debugElement.nativeElement.querySelector('.classification-details__action-toolbar').children[1]
.children[1];
expect(button).toBeTruthy();
expect(button.title).toBe('Restore Previous Version');
expect(button.textContent).toContain('Undo Changes');
expect(button.textContent).toContain('restore');
let isActionDispatched = false;
actions$.pipe(ofActionDispatched(RestoreSelectedClassification)).subscribe(() => (isActionDispatched = true));
@ -298,22 +312,39 @@ describe('ClassificationDetailsComponent', () => {
expect(isActionDispatched).toBe(true);
});
it('should call onCopy() when button is clicked', () => {
const button = debugElement.nativeElement.querySelector('.classification__menu-bar').children[2];
it('should display button to show more actions', () => {
const button = debugElement.nativeElement.querySelector('#action-toolbar__more-buttons');
expect(button).toBeTruthy();
expect(button.title).toBe('Copy');
component.onCopy = jest.fn().mockImplementation();
button.click();
fixture.detectChanges();
const buttonsInDropdown = debugElement.queryAll(By.css('.action-toolbar__dropdown'));
expect(buttonsInDropdown.length).toEqual(3);
});
it('should call onCopy() when button is clicked', () => {
const button = debugElement.nativeElement.querySelector('#action-toolbar__more-buttons');
expect(button).toBeTruthy();
button.click();
fixture.detectChanges();
const copyButton = debugElement.queryAll(By.css('.action-toolbar__dropdown'))[0];
expect(copyButton.nativeElement.textContent).toContain('content_copy');
expect(copyButton.nativeElement.textContent).toContain('Copy');
component.onCopy = jest.fn().mockImplementation();
copyButton.nativeElement.click();
expect(component.onCopy).toHaveBeenCalled();
});
it('should call onRemoveClassification() when button is clicked', () => {
const button = debugElement.nativeElement.querySelector('.classification__menu-bar').children[3];
const button = debugElement.nativeElement.querySelector('#action-toolbar__more-buttons');
expect(button).toBeTruthy();
expect(button.title).toBe('Delete');
button.click();
fixture.detectChanges();
const deleteButton = debugElement.queryAll(By.css('.action-toolbar__dropdown'))[1];
expect(deleteButton.nativeElement.textContent).toContain('delete');
expect(deleteButton.nativeElement.textContent).toContain('Delete');
const onRemoveClassificationSpy = jest.spyOn(component, 'onRemoveClassification');
button.click();
deleteButton.nativeElement.click();
expect(onRemoveClassificationSpy).toHaveBeenCalled();
onRemoveClassificationSpy.mockReset();
@ -323,27 +354,54 @@ describe('ClassificationDetailsComponent', () => {
expect(showDialogSpy).toHaveBeenCalled();
});
it('should call onClose() when button is clicked', () => {
const button = debugElement.nativeElement.querySelector('#action-toolbar__more-buttons');
expect(button).toBeTruthy();
button.click();
fixture.detectChanges();
const closeButton = debugElement.queryAll(By.css('.action-toolbar__dropdown'))[2];
expect(closeButton.nativeElement.textContent).toContain('close');
expect(closeButton.nativeElement.textContent).toContain('close');
component.onCloseClassification = jest.fn().mockImplementation();
closeButton.nativeElement.click();
expect(component.onCloseClassification).toHaveBeenCalled();
});
/* DETAILED FIELDS */
it('should display field-error-display component', () => {
expect(debugElement.nativeElement.querySelector('taskana-shared-field-error-display')).toBeTruthy();
});
it('should display number-picker component', () => {
expect(debugElement.nativeElement.querySelector('taskana-shared-number-picker')).toBeTruthy();
it('should display form field for key', () => {
expect(debugElement.nativeElement.querySelector('#classification-key')).toBeTruthy();
});
it('should select category when button is clicked', () => {
component.classification.category = 'A';
component.getAvailableCategories = jest.fn().mockImplementation((type) => of(['B', 'C']));
fixture.detectChanges();
const button = debugElement.nativeElement.querySelector('.detailed-fields__categories');
expect(button).toBeTruthy();
button.click();
expect(component.classification.category).toBe('B');
it('should display form field for name', () => {
expect(debugElement.nativeElement.querySelector('#classification-name')).toBeTruthy();
});
it('should display form field for service level', () => {
expect(debugElement.nativeElement.querySelector('#classification-service-level')).toBeTruthy();
});
it('should display form field for priority', () => {
expect(debugElement.nativeElement.querySelector('#classification-priority')).toBeTruthy();
});
it('should display form field for domain', () => {
expect(debugElement.nativeElement.querySelector('#classification-domain')).toBeTruthy();
});
it('should display form field for application entry point', () => {
expect(debugElement.nativeElement.querySelector('#classification-application-entry-point')).toBeTruthy();
});
it('should display form field for description', () => {
expect(debugElement.nativeElement.querySelector('#classification-description')).toBeTruthy();
});
it('should change isValidInDomain when button is clicked', () => {
const button = debugElement.nativeElement.querySelector('.detailed-fields__domain').children[2];
const button = debugElement.nativeElement.querySelector('.domain-and-category__domain-checkbox-icon').parentNode;
expect(button).toBeTruthy();
component.classification.isValidInDomain = false;
button.click();

View File

@ -28,7 +28,8 @@ import {
RestoreSelectedClassification,
SaveModifiedClassification,
SelectClassification,
CopyClassification
CopyClassification,
DeselectClassification
} from '../../../shared/store/classification-store/classification.actions';
@Component({
@ -43,7 +44,6 @@ export class ClassificationDetailsComponent implements OnInit, OnDestroy {
@Select(ClassificationSelectors.selectCategories) categories$: Observable<string[]>;
@Select(EngineConfigurationSelectors.selectCategoryIcons) categoryIcons$: Observable<ClassificationCategoryImages>;
@Select(ClassificationSelectors.selectedClassificationType) selectedClassificationType$: Observable<string>;
@Select(ClassificationSelectors.selectClassificationTypesObject) classificationTypes$: Observable<CategoriesResponse>;
@Select(ClassificationSelectors.selectedClassification) selectedClassification$: Observable<Classification>;
@Select(ClassificationSelectors.getBadgeMessage) badgeMessage$: Observable<string>;
@ -127,8 +127,8 @@ export class ClassificationDetailsComponent implements OnInit, OnDestroy {
}
}
selectCategory(category: string) {
this.classification.category = category;
onCloseClassification() {
this.store.dispatch(new DeselectClassification());
}
getCategoryIcon(category: string): Observable<Pair> {
@ -157,13 +157,6 @@ export class ClassificationDetailsComponent implements OnInit, OnDestroy {
return `custom${customNumber}`;
}
getAvailableCategories(type: string): Observable<string[]> {
return this.classificationTypes$.pipe(
take(1),
map((classTypes) => classTypes[type])
);
}
async onSave() {
this.requestInProgressService.setRequestInProgress(true);
if (typeof this.classification.classificationId === 'undefined') {

View File

@ -1,63 +1,73 @@
<div class="classification-list-full-height">
<div class="classification-list">
<!-- ACTION TOOLBAR -->
<li class="list-group-item tab-align" id="wb-action-toolbar">
<div class="row">
<div class="col-xs-6 btn-group">
<!-- ACTION TOOLBAR -->
<section class="classification-list__action-toolbar">
<div class="classification-list__action-buttons">
<button mat-flat-button class="action-toolbar__add-button mr-1" matTooltip="Create new classification"
(click)="addClassification()">
Add
<mat-icon class="md-20">add</mat-icon>
</button>
<button class="btn btn-default add-classification-button" type="button" (click)="addClassification()" data-toggle="tooltip" title="Add">
<span class="material-icons md-20 green-blue">add_circle_outline</span>
</button>
<taskana-administration-import-export
class="classification-list__import-export" [currentSelection]="taskanaType.CLASSIFICATIONS">
</taskana-administration-import-export>
<taskana-administration-import-export
class ="btn-group" [currentSelection]="taskanaType.CLASSIFICATIONS">
</taskana-administration-import-export>
</div>
<div class="col-xs-6">
<taskana-administration-classification-types-selector
class="pull-right">
</taskana-administration-classification-types-selector>
</div>
</div>
</li>
<button mat-stroked-button matTooltip="Display filter options" (click)="displayFilter()">
<mat-icon *ngIf="!showFilter">search</mat-icon>
<mat-icon *ngIf="showFilter" color="warn">clear</mat-icon>
</button>
</div>
<!-- FILTER -->
<div class="col-xs-2 category-filter">
<button class="btn btn-default" data-toggle="dropdown" type="button" id="dropdown-classification-filter" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="true">
<svg-icon class="blue selected-category" *ngIf="selectedCategory else category_unselected" [src]="(getCategoryIcon(selectedCategory) | async)?.name"
data-toggle="tooltip"></svg-icon>
<ng-template #category_unselected>
<svg-icon data-toggle="tooltip" title="All" class="blue no-selected-category" src="./assets/icons/asterisk.svg"></svg-icon>
</ng-template>
</button>
<ul class="dropdown-menu dropdown-menu-classification" role="menu">
<li>
<a class="category-all" type="button" (click)="selectCategory('');" data-toggle="tooltip">
<svg-icon class="blue" src="./assets/icons/asterisk.svg"></svg-icon>
All
</a>
<a class="category-list" *ngFor="let category of categories$ | async" type="button" (click)="selectCategory(category);" data-toggle="tooltip">
<svg-icon class="blue categories" [src]="(getCategoryIcon(category) | async)?.name" data-toggle="tooltip" [title]="(getCategoryIcon(category) | async)?.text"></svg-icon>
{{category}}
</a>
</li>
</ul>
</div>
<div class="col-xs-8">
<input class="filter-input" [ngModel]="inputValue" (ngModelChange)="inputValue = $event" placeholder="Filter classifications">
</div>
<!-- FILTER -->
<div class="classification-list__filter" *ngIf="showFilter">
<div class="classification-list__category-filter">
<button mat-icon-button class="mr-2 category-filter__filter-button" [matMenuTriggerFor]="menu"
matTooltip="Filter Category">
<mat-icon *ngIf="selectedCategory == ''">filter_list</mat-icon>
<svg-icon class="category-filter__icons" [src]="(getCategoryIcon(selectedCategory) | async)?.name"
[title]="(getCategoryIcon(selectedCategory) | async)?.text"
*ngIf="selectedCategory != ''">
</svg-icon>
</button>
<div class="col-xs-12 horizontal-bottom-divider"></div>
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="selectCategory('')" class="category-filter__all-button">
<svg-icon class="category-filter__categories pr-2" src="./assets/icons/asterisk.svg"
[title]="(getCategoryIcon('all') | async)?.text"></svg-icon>
<span>All</span>
</button>
<button mat-menu-item *ngFor="let category of categories$ | async" (click)="selectCategory(category)">
<svg-icon class="category-filter__categories pr-2" [src]="(getCategoryIcon(category) | async)?.name"
[title]="(getCategoryIcon(category) | async)?.text"></svg-icon>
<span> {{category}} </span>
</button>
</mat-menu>
</div>
<!-- CLASSIFICATION TREE -->
<taskana-shared-spinner class="col-xs-12" [isRunning]="requestInProgress" positionClass="centered-spinner-whole-screen"></taskana-shared-spinner>
<taskana-administration-tree class="col-xs-12" *ngIf="(classifications && classifications.length) else empty_classifications"
[filterText]="inputValue" [filterIcon]="selectedCategory" (switchTaskanaSpinnerEmit)="requestInProgress=$event"></taskana-administration-tree>
<ng-template #empty_classifications>
<div *ngIf="!requestInProgress" class="col-xs-12 container-no-items center-block no-classifications">
<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 class="filter__input">
<mat-form-field appearance="legacy" floatLabel="auto" class="filter__input-field">
<mat-label>Filter classification</mat-label>
<input matInput [ngModel]="inputValue" (ngModelChange)="inputValue = $event" matTooltip="Type to filter classifications">
</mat-form-field>
</div>
<taskana-administration-classification-types-selector
class="pull-right">
</taskana-administration-classification-types-selector>
</div>
</section>
<!-- CLASSIFICATION TREE -->
<taskana-shared-spinner [isRunning]="requestInProgress"
positionClass="centered-spinner-whole-screen"></taskana-shared-spinner>
<taskana-administration-tree *ngIf="(classifications && classifications.length) else empty_classifications"
[filterText]="inputValue" [filterIcon]="selectedCategory"
(switchTaskanaSpinnerEmit)="requestInProgress=$event"></taskana-administration-tree>
<ng-template #empty_classifications>
<div *ngIf="!requestInProgress" class="classification-list__no-items">
<h3 class="grey">There are no classifications</h3>
</div>
</ng-template>
</div>

View File

@ -1,38 +1,53 @@
.classification-list-full-height {
@import 'src/theme/_colors.scss';
.classification-list {
height: calc(100vh - 55px);
width: 450px;
}
.list-group-item {
padding: 5px 0px 2px 1px;
.classification-list__action-toolbar {
padding: 0 16px;
min-height: 68px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.classification-list__action-buttons {
display: flex;
border: none;
margin-bottom: 0;
padding: 16px 4px 8px 4px;
}
.action-toolbar__add-button {
background-color: $aquamarine;
color: white;
}
.tab-align {
margin-bottom: 0px;
border-bottom: 1px dotted #ddd;
padding: 8px 12px 8px 4px;
& > div {
margin: 6px 0px;
}
.classification-list__import-export {
display: flex;
}
.classification-list__filter {
display: flex;
}
input.filter-input {
border: solid 1px grey;
margin: 10px 2px;
height: 32px;
.classification-list__category-filter {
padding-top: 7px;
}
.category-filter__icons {
height: 33px;
width: 16px;
}
.category-filter__categories {
fill: #555;
margin: 0;
top: -2px;
}
.filter__input {
width: 100%;
padding-left: 10px;
margin-right: 12px;
}
.category-filter {
margin: 7px 2px;
.filter__input-field {
width: 100% !important;
}
.dropdown-menu-classification {
margin-left: 15px;
min-width: 0px;
}
div.category-filter svg-icon {
position: initial;
.classification-list__no-items {
text-align: center;
padding-top: 150px;
}

View File

@ -12,6 +12,12 @@ import { ImportExportService } from '../../services/import-export.service';
import { Observable, of } from 'rxjs';
import { CreateClassification } from '../../../shared/store/classification-store/classification.actions';
import { EngineConfigurationState } from '../../../shared/store/engine-configuration-store/engine-configuration.state';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatFormFieldModule } from '@angular/material/form-field';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatInputModule } from '@angular/material/input';
import { By } from '@angular/platform-browser';
@Component({ selector: 'taskana-administration-import-export', template: '' })
class ImportExportStub {
@ -76,7 +82,14 @@ describe('ClassificationListComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [NgxsModule.forRoot([ClassificationState, EngineConfigurationState])],
imports: [
NgxsModule.forRoot([ClassificationState, EngineConfigurationState]),
MatIconModule,
MatMenuModule,
MatFormFieldModule,
MatInputModule,
BrowserAnimationsModule
],
declarations: [
ClassificationListComponent,
ClassificationTypesSelectorStub,
@ -113,7 +126,7 @@ describe('ClassificationListComponent', () => {
/* HTML: ACTION TOOLBAR */
it('should call CreateClassification when add-classification button is clicked', async () => {
const button = debugElement.nativeElement.querySelector('.add-classification-button');
const button = debugElement.nativeElement.querySelector('.action-toolbar__add-button');
expect(button).toBeTruthy();
let actionDispatched = false;
actions$.pipe(ofActionDispatched(CreateClassification)).subscribe(() => (actionDispatched = true));
@ -125,7 +138,9 @@ describe('ClassificationListComponent', () => {
expect(debugElement.nativeElement.querySelector('taskana-administration-import-export')).toBeTruthy();
});
it('should display classification-types-selector component', () => {
it('should display classification-types-selector component when showFilter is true', () => {
component.showFilter = true;
fixture.detectChanges();
const typesSelectorComponent = debugElement.nativeElement.querySelector(
'taskana-administration-classification-types-selector'
);
@ -133,31 +148,43 @@ describe('ClassificationListComponent', () => {
});
/* HTML: FILTER */
it('should display specific icon when selectedCategory is true', () => {
component.selectedCategory = 'EXTERNAL';
it('should display filter input field when showFilter is true', () => {
component.showFilter = true;
fixture.detectChanges();
expect(debugElement.nativeElement.querySelector('.selected-category')).toBeTruthy();
const button = debugElement.nativeElement.querySelector('.filter__input-field');
expect(button).toBeTruthy();
expect(button.textContent).toBe('Filter classification');
});
it('should display universal icon for categories when selectedCategory is false', () => {
expect(debugElement.nativeElement.querySelector('svg-icon.no-selected-category')).toBeTruthy();
it('should display filter button when showFilter is true', () => {
component.showFilter = true;
fixture.detectChanges();
const button = debugElement.nativeElement.querySelector('.category-filter__filter-button');
expect(button).toBeTruthy();
expect(button.textContent).toBe('filter_list');
});
it('should change selectedCategory property when button is clicked', () => {
component.showFilter = true;
fixture.detectChanges();
const filterButton = debugElement.nativeElement.querySelector('.category-filter__filter-button');
filterButton.click();
fixture.detectChanges();
component.selectedCategory = 'EXTERNAL';
const button = debugElement.nativeElement.querySelector('.category-all');
button.click();
const allButton = debugElement.query(By.css('.category-filter__all-button'));
expect(allButton).toBeTruthy();
allButton.nativeElement.click();
expect(component.selectedCategory).toBe('');
});
it('should display list of categories which can be selected', () => {
expect(debugElement.nativeElement.querySelector('.category-all').textContent.trim()).toBe('All');
const categories = fixture.debugElement.nativeElement.getElementsByClassName('category-list');
expect(categories.length).toBe(3);
expect(categories[0].textContent.trim()).toBe('EXTERNAL');
expect(categories[1].textContent.trim()).toBe('MANUAL');
expect(categories[2].textContent.trim()).toBe('AUTOMATIC');
component.showFilter = true;
fixture.detectChanges();
const filterButton = debugElement.nativeElement.querySelector('.category-filter__filter-button');
filterButton.click();
fixture.detectChanges();
const matMenu = debugElement.queryAll(By.css('.category-filter__categories'));
expect(matMenu.length).toBe(4);
});
/* HTML: CLASSIFICATION TREE */
@ -172,10 +199,9 @@ describe('ClassificationListComponent', () => {
});
it('should display icon and text when no classifications exist', () => {
const noClassifications = debugElement.nativeElement.querySelector('.no-classifications');
expect(noClassifications.childNodes.length).toBe(2);
const noClassifications = debugElement.nativeElement.querySelector('.classification-list__no-items');
expect(noClassifications.childNodes.length).toBe(1);
expect(noClassifications.childNodes[0].textContent).toBe('There are no classifications');
expect(noClassifications.childNodes[1].tagName).toBe('SVG-ICON');
});
/* TS: getCategoryIcon() */

View File

@ -29,6 +29,7 @@ export class ClassificationListComponent implements OnInit, OnDestroy {
requestInProgress = true;
inputValue: string;
selectedCategory = '';
showFilter = false;
@Select(ClassificationSelectors.classificationTypes) classificationTypes$: Observable<string[]>;
@Select(ClassificationSelectors.selectedClassificationType) classificationTypeSelected$: Observable<string>;
@ -85,18 +86,25 @@ export class ClassificationListComponent implements OnInit, OnDestroy {
this.location.go(this.location.path().replace(/(classifications).*/g, 'classifications/new-classification'));
}
getCategoryIcon(category: string): Observable<Pair> {
return this.categoryIcons$.pipe(
map((iconMap) => {
if (category === '') {
return new Pair(iconMap['all'], 'All');
}
return iconMap[category]
? new Pair(iconMap[category], category)
: new Pair(iconMap.missing, 'Category does not match with the configuration');
})
);
}
selectCategory(category: string) {
this.selectedCategory = category;
}
getCategoryIcon(category: string): Observable<Pair> {
return this.categoryIcons$.pipe(
map((iconMap) =>
iconMap[category]
? new Pair(iconMap[category], category)
: new Pair(iconMap.missing, 'Category does not match with the configuration')
)
);
displayFilter() {
this.showFilter = !this.showFilter;
}
ngOnDestroy() {

View File

@ -1,10 +1,8 @@
<div class="no-gutter">
<div class="col-xs-12 col-md-4 vertical-right-divider">
<taskana-administration-classification-list></taskana-administration-classification-list>
</div>
<div class="col-xs-12 col-md-8" *ngIf="showDetail; else showEmptyPage">
<taskana-administration-classification-details></taskana-administration-classification-details>
</div>
<div class="classification-overview">
<taskana-administration-classification-list></taskana-administration-classification-list>
<div class="vertical-right-divider"></div>
<taskana-administration-classification-details *ngIf="showDetail; else showEmptyPage"></taskana-administration-classification-details>
<ng-template #showEmptyPage>
<div class="hidden-xs hidden-sm col-md-8 container-no-items">

View File

@ -0,0 +1,10 @@
.classification-overview {
display: flex;
flex-direction: row;
width: 100%;
overflow: hidden;
align-items: stretch;
}
taskana-administration-classification-details {
width: 100%
}

View File

@ -1,17 +1,8 @@
<div class="dropdown clearfix btn-group">
<button type="button" class="btn btn-default selected-type"> {{classificationTypeSelected$ | async}}</button>
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true"
aria-expanded="false">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<div class="dropdown-menu dropdown-menu-right popup" aria-labelledby="sortingDropdown">
<mat-radio-group name="classificationTypeSelector" color="accent" class="radio-group">
<mat-radio-button class="classification-types" *ngFor="let classificationType of classificationTypes$ | async"
name="classificationTypeSelector" id="select-{{classificationType}}" [checked]="classificationType === (classificationTypeSelected$ | async)"
(change)="select(classificationType)" [value]="classificationType">
{{classificationType}}
</mat-radio-button>
</mat-radio-group>
</div>
</div>
<mat-form-field class="types-selector">
<mat-label>Type</mat-label>
<mat-select [value]="classificationTypeSelected$ | async" class="types-selector__selected-type" matTooltip="Change type">
<mat-option class="types-selector__options" *ngFor="let classificationType of classificationTypes$ | async" [value]="classificationType" (click)="select(classificationType)">
{{ classificationType }}
</mat-option>
</mat-select>
</mat-form-field>

View File

@ -1,3 +1,6 @@
mat-form-field {
max-width: 120px !important;
}
.radio-group {
display: flex;
flex-direction: column;

View File

@ -3,12 +3,14 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { DebugElement } from '@angular/core';
import { NgxsModule, Store } from '@ngxs/store';
import { ClassificationState } from '../../../shared/store/classification-store/classification.state';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { ClassificationsService } from '../../../shared/services/classifications/classifications.service';
import { ClassificationCategoriesService } from '../../../shared/services/classification-categories/classification-categories.service';
import { DomainService } from '../../../shared/services/domain/domain.service';
import { MatRippleModule } from '@angular/material/core';
import { classificationStateMock } from '../../../shared/store/mock-data/mock-store';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { By } from '@angular/platform-browser';
const classificationServiceSpy = jest.fn();
const classificationCategoriesServiceSpy = jest.fn();
@ -17,13 +19,18 @@ const domainServiceSpy = jest.fn();
describe('ClassificationTypesSelectorComponent', () => {
let fixture: ComponentFixture<ClassificationTypesSelectorComponent>;
let debugElement: DebugElement;
let app: ClassificationTypesSelectorComponent;
let component: ClassificationTypesSelectorComponent;
let store: Store;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [NgxsModule.forRoot([ClassificationState]), MatRippleModule],
declarations: [ClassificationTypesSelectorComponent, MatRadioButton, MatRadioGroup],
imports: [
NgxsModule.forRoot([ClassificationState]),
MatFormFieldModule,
MatSelectModule,
BrowserAnimationsModule
],
declarations: [ClassificationTypesSelectorComponent],
providers: [
{ provide: ClassificationsService, useClass: classificationServiceSpy },
{ provide: ClassificationCategoriesService, useClass: classificationCategoriesServiceSpy },
@ -33,7 +40,7 @@ describe('ClassificationTypesSelectorComponent', () => {
fixture = TestBed.createComponent(ClassificationTypesSelectorComponent);
debugElement = fixture.debugElement;
app = fixture.debugElement.componentInstance;
component = fixture.debugElement.componentInstance;
store = TestBed.inject(Store);
store.reset({
...store.snapshot(),
@ -43,18 +50,28 @@ describe('ClassificationTypesSelectorComponent', () => {
}));
it('should create the app', () => {
expect(app).toBeTruthy();
expect(component).toBeTruthy();
});
it('should display form-field for types-selector', () => {
const button = debugElement.nativeElement.getElementsByClassName('types-selector');
expect(button).toBeTruthy();
});
it('should display selected classification type', () => {
const button = fixture.debugElement.nativeElement.getElementsByClassName('selected-type');
expect(button[0].textContent.trim()).toBe('DOCUMENT');
fixture.detectChanges();
const button = debugElement.nativeElement.querySelector('.types-selector__selected-type');
expect(button.textContent).toBe('DOCUMENT');
});
it('should display list of classification types', () => {
const radioButtons = fixture.debugElement.nativeElement.getElementsByClassName('classification-types');
expect(radioButtons.length).toBe(2);
expect(radioButtons[0].textContent.trim()).toBe('TASK');
expect(radioButtons[1].textContent.trim()).toBe('DOCUMENT');
it('should display dropdown with 2 objects', () => {
const dropdownButton = debugElement.nativeElement.querySelector('.types-selector__selected-type');
expect(dropdownButton).toBeTruthy();
dropdownButton.click();
fixture.detectChanges();
const options = debugElement.queryAll(By.css('.types-selector__options'));
expect(options.length).toBe(2);
expect(options[0].nativeElement.textContent.trim()).toBe('TASK');
expect(options[1].nativeElement.textContent.trim()).toBe('DOCUMENT');
});
});

View File

@ -1,23 +1,23 @@
<button type="button" [ngClass]="{disabled: uploadservice?.isInUse}" (click)="selectedFile.click()" data-toggle="tooltip" title="Import" class="btn btn-default">
<span class="material-icons md-20 green-blue">cloud_upload</span>
<button mat-stroked-button class="mr-1" matTooltip="Import classification" [ngClass]="{disabled: uploadService?.isInUse}" (click)="selectedFile.click()" title="Import">
Import
<mat-icon>cloud_upload</mat-icon>
</button>
<form class="hidden" id="upload_form" enctype="multipart/form-data" method="post">
<form class="hidden" enctype="multipart/form-data" method="post">
<input #selectedFile type="file" accept=".json" (change)="uploadFile()" class="hide" />
</form>
<ul class="dropdown-menu">
<li>
<a href="javascript:void(0)" class="dropdown-item" (click)="export()">
<label>All Domains</label>
</a>
</li>
<div role="separator" class="divider"></div>
<li *ngFor="let domain of domains">
<a href="javascript:void(0)" class="dropdown-item" (click)="export(domain)">
<label>{{domain === '' ? 'Master' : domain}}</label>
</a>
</li>
</ul>
<button [ngClass]="{disabled: uploadservice?.isInUse}" type="button" data-toggle="dropdown" title="Export" class="btn btn-default">
<span class="material-icons md-20 red">cloud_download</span>
<button mat-stroked-button class="mr-1" matTooltip="Export classification" [matMenuTriggerFor]="menu" [ngClass]="{disabled: uploadService?.isInUse}" title="Export">
Export
<mat-icon>cloud_download</mat-icon>
</button>
<mat-menu #menu="matMenu">
<button mat-menu-item href="javascript:void(0)" (click)="export()">
All Domains
</button>
<button mat-menu-item *ngFor="let domain of domains" href="javascript:void(0)" (click)="export(domain)">
{{domain === '' ? 'Master' : domain}}
</button>
</mat-menu>

View File

@ -1,3 +1,9 @@
.hide {
display: none;
}
mat-icon {
margin-left: 3px;
}
button {
color: #555
}

View File

@ -29,7 +29,7 @@ export class ImportExportComponent implements OnInit {
private workbasketDefinitionService: WorkbasketDefinitionService,
private classificationDefinitionService: ClassificationDefinitionService,
private notificationsService: NotificationService,
public uploadservice: UploadService,
public uploadService: UploadService,
private errorsService: NotificationService,
private importExportService: ImportExportService
) {}
@ -67,14 +67,14 @@ export class ImportExportComponent implements OnInit {
ajax.setRequestHeader('Authorization', 'Basic YWRtaW46YWRtaW4=');
}
ajax.send(formdata);
this.uploadservice.isInUse = true;
this.uploadservice.setCurrentProgressValue(1);
this.uploadService.isInUse = true;
this.uploadService.setCurrentProgressValue(1);
}
}
progressHandler(event) {
const percent = (event.loaded / event.total) * 100;
this.uploadservice.setCurrentProgressValue(Math.round(percent));
this.uploadService.setCurrentProgressValue(Math.round(percent));
}
private checkFormatFile(file): boolean {
@ -90,8 +90,8 @@ export class ImportExportComponent implements OnInit {
}
private resetProgress() {
this.uploadservice.setCurrentProgressValue(0);
this.uploadservice.isInUse = false;
this.uploadService.setCurrentProgressValue(0);
this.uploadService.isInUse = false;
this.selectedFileInput.nativeElement.value = '';
}

View File

@ -2,7 +2,7 @@
(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) | async)?.name" data-toggle="tooltip"
<svg-icon *ngIf="node.data.category" class="fa-fw pr-1" [src]="(getCategoryIcon(node.data.category) | async)?.name" data-toggle="tooltip"
[title]="(getCategoryIcon(node.data.category) | async)?.text"></svg-icon>
</span>
<span>

View File

@ -2,6 +2,8 @@
vertical-align: text-top;
}
svg-icon.blue.fa-fw {
position: initial;
svg-icon {
fill: #555;
top: -5px !important;
}

View File

@ -1,4 +1,4 @@
<div class="container-scrollable">
<div class="container container-scrollable" style="min-width: 70vw;">
<taskana-shared-spinner [isRunning]="requestInProgress"></taskana-shared-spinner>
<div id="workbasket-details" *ngIf="workbasket && !requestInProgress">
<ul class="nav nav-tabs" role="tablist">

View File

@ -1,4 +1,4 @@
<div class="footer-space-pagination-list">
<div class="workbasket-list footer-space-pagination-list">
<div #wbToolbar>
<taskana-administration-workbasket-list-toolbar [workbaskets]="workbasketsSummary$ | async" (performFilter)="performFilter($event)"
(performSorting)="performSorting($event)" [workbasketDefaultSortBy]="workbasketDefaultSortBy">

View File

@ -1,3 +1,6 @@
.workbasket-list {
min-width: 30vw;
}
.row.list-group {
margin-left: 2px;
}

View File

@ -1,8 +1,8 @@
<div class="no-gutter">
<div class="col-xs-12 col-md-4 vertical-right-divider">
<div class="workbasket-overview">
<div class="vertical-right-divider">
<taskana-administration-workbasket-list></taskana-administration-workbasket-list>
</div>
<div class="col-xs-12 col-md-8" *ngIf="showDetail; else showEmptyPage">
<div *ngIf="showDetail; else showEmptyPage">
<taskana-administration-workbasket-details></taskana-administration-workbasket-details>
</div>

View File

@ -0,0 +1,5 @@
.workbasket-overview {
width: 100%;
display: flex;
}

View File

@ -12,7 +12,7 @@ import {
} from '../../../shared/store/workbasket-store/workbasket.actions';
@Component({
selector: 'app-workbasket-overview',
selector: 'taskana-administration-workbasket-overview',
templateUrl: './workbasket-overview.component.html',
styleUrls: ['./workbasket-overview.component.scss']
})

View File

@ -1,6 +1,7 @@
<taskana-shared-nav-bar></taskana-shared-nav-bar>
<div (window:resize)="onResize()" class="container-fluid container-main">
<div class="row ">
<div (window:resize)="onResize()" class="">
<div class="taskana-main">
<router-outlet></router-outlet>
<taskana-shared-spinner [isRunning]="requestInProgress" isModal=true></taskana-shared-spinner>
<taskana-shared-progress-bar [hidden]="currentProgressValue === 0" currentValue={{currentProgressValue}}></taskana-shared-progress-bar>

View File

@ -1,4 +1,4 @@
<div class="no-gutter">
<div class="master-detail row row-no-gutters">
<div class="{{showDetail? 'col-md-4 hidden-xs hidden-sm':'col-xs-12 col-md-4'}} vertical-right-divider">
<router-outlet name="master"></router-outlet>
</div>

View File

@ -1,3 +1,7 @@
.master-detail {
min-width: 100vw;
}
.center-block.no-detail {
text-align: center;
}

View File

@ -5,6 +5,7 @@
}
.navbar-inverse {
top: 0;
border: none;
background-color: $dark-green;
box-shadow: 0px 1px 5px -1px black;

View File

@ -1,7 +1,7 @@
import { TestBed, async } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { Customisation, CustomisationContent } from 'app/shared/models/customisation';
import { ClassificationCategoriesService, missingIcon } from './classification-categories.service';
import { asteriskIcon, ClassificationCategoriesService, missingIcon } from './classification-categories.service';
describe('ClassificationCategoriesService', () => {
let categoryService: ClassificationCategoriesService;
@ -19,7 +19,7 @@ describe('ClassificationCategoriesService', () => {
it('should insert missing icon into customisation', async(() => {
const expectedCustomisationContent: CustomisationContent = {
classifications: { categories: { missing: missingIcon } }
classifications: { categories: { all: asteriskIcon, missing: missingIcon } }
};
const expectedCustomisation: Customisation = { EN: expectedCustomisationContent, DE: expectedCustomisationContent };

View File

@ -9,6 +9,7 @@ import { Customisation } from '../../models/customisation';
const customisationUrl = 'environments/data-sources/taskana-customization.json';
export const missingIcon = 'assets/icons/categories/missing-icon.svg';
export const asteriskIcon = './assets/icons/asterisk.svg';
export interface CategoriesResponse {
[key: string]: string[];
@ -29,11 +30,12 @@ export class ClassificationCategoriesService {
Object.keys(customisation).forEach((lang) => {
if (customisation[lang]?.classifications?.categories) {
customisation[lang].classifications.categories.missing = missingIcon;
customisation[lang].classifications.categories.all = asteriskIcon;
} else {
if (customisation[lang]?.classifications) {
customisation[lang].classifications.categories = { missing: missingIcon };
customisation[lang].classifications.categories = { missing: missingIcon, all: asteriskIcon };
} else {
customisation[lang].classifications = { categories: { missing: missingIcon } };
customisation[lang].classifications = { categories: { missing: missingIcon, all: asteriskIcon } };
}
}
});

View File

@ -52,6 +52,8 @@ import { HttpClientInterceptor } from './services/http-client-interceptor/http-c
import { AccessIdsService } from './services/access-ids/access-ids.service';
import { ToastComponent } from './components/toast/toast.component';
import { DialogPopUpComponent } from './components/popup/dialog-pop-up.component';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
const MODULES = [
CommonModule,
@ -97,7 +99,7 @@ const DECLARATIONS = [
@NgModule({
declarations: DECLARATIONS,
imports: [MODULES, MatRadioModule],
imports: [MODULES, MatRadioModule, MatFormFieldModule, MatInputModule],
exports: DECLARATIONS,
providers: [
{

View File

@ -19,11 +19,6 @@ export class ClassificationSelectors {
return state.classificationTypes[state.selectedClassificationType];
}
@Selector([ClassificationState])
static selectClassificationTypesObject(state: ClassificationStateModel): CategoriesResponse {
return state.classificationTypes;
}
@Selector([ClassificationState])
static classifications(state: ClassificationStateModel): Classification[] {
return state.classifications;

View File

@ -145,7 +145,7 @@ svg-icon.fa-fw > svg {
svg-icon {
position: relative;
top: 4px;
top: -5px;
}
.panel-default > .panel-heading .badge.warning {
@ -483,8 +483,18 @@ li.list-group-item:hover {
background-color: white;
}
.node-content-wrapper {
margin: 0 18px !important;
}
/* our header has a z-index of 1031 and the default z-index of the
mat-dialog is just 1000 which leads to undesirable overlap*/
.cdk-overlay-container {
z-index: 1337;
}
.mat-menu-content {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.mat-select-value-text {
color: #4a5568 !important;
}