Merge pull request #2 in ~VLE2FE/dfop-ui from samples to development

* commit '93878e4e0a8f509277248ceba7c695c96c2b72b2':
  implemented sample references
  added documentation and img-magnifier
  basic account management (Login/Logout)
  finished filters
  sorting implemented
  tests done except for sample and samples component
  first version of sample.component finished
This commit is contained in:
Veit Lukas (PEA4-Fe) 2020-07-22 10:52:39 +02:00
commit 15aeeb27ee
71 changed files with 2567 additions and 320 deletions

View File

@ -26,7 +26,8 @@
"assets": [
"src/favicon.ico",
"src/assets",
{ "glob": "**/*", "input": "./node_modules/@inst-iot/bosch-angular-ui-components/assets", "output": "./assets" }
{ "glob": "**/*", "input": "./node_modules/@inst-iot/bosch-angular-ui-components/assets", "output": "./assets" },
"src/Staticfile"
],
"styles": [
"src/styles.scss"

9
manifest.yml Normal file
View File

@ -0,0 +1,9 @@
---
applications:
- name: definma
path: dist/UI
buildpacks:
- staticfile_buildpack
memory: 128M
instances: 1
stack: cflinuxfs3

78
package-lock.json generated
View File

@ -1887,11 +1887,10 @@
}
},
"@inst-iot/bosch-angular-ui-components": {
"version": "0.5.30",
"resolved": "https://rb-artifactory.bosch.com:443/artifactory/api/npm/iot-insights-release-local/@inst-iot/bosch-angular-ui-components/-/@inst-iot/bosch-angular-ui-components-0.5.30.tgz",
"integrity": "sha1-s7Xl3h1BCr4MQPi118S5otrA4Cc=",
"version": "file:../Bosch-UI-Components/bosch-angular-ui-components/dist-lib/inst-iot-bosch-angular-ui-components-0.6.0.tgz",
"integrity": "sha512-A4nKOvpdKzq+GWSZlL7U511Ii1vdSA905Q0tru7jAzpBzjRaYqCTiCvsAjRDLM+gVPpzgZ8HpMYfNfhMoNlG/w==",
"requires": {
"tslib": "^1.9.0"
"tslib": "^1.10.0"
}
},
"@istanbuljs/schema": {
@ -2002,6 +2001,14 @@
}
}
},
"@types/chart.js": {
"version": "2.9.22",
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.22.tgz",
"integrity": "sha512-CneMxwh2T5fyMpXE5fuprTTmFtlLyZUFq1A3laUrCgOblDzupgiohrFg3jjsTIrqRI5K4qLZdrLN4zT9/MY5Dw==",
"requires": {
"moment": "^2.10.2"
}
},
"@types/color-name": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
@ -3355,6 +3362,37 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
"chart.js": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.3.tgz",
"integrity": "sha512-+2jlOobSk52c1VU6fzkh3UwqHMdSlgH1xFv9FKMqHiNCpXsGPQa/+81AFa+i3jZ253Mq9aAycPwDjnn1XbRNNw==",
"requires": {
"chartjs-color": "^2.1.0",
"moment": "^2.10.2"
}
},
"chartjs-color": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz",
"integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==",
"requires": {
"chartjs-color-string": "^0.6.0",
"color-convert": "^1.9.3"
}
},
"chartjs-color-string": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz",
"integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==",
"requires": {
"color-name": "^1.0.0"
}
},
"chartjs-plugin-datalabels": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-0.7.0.tgz",
"integrity": "sha512-PKVUX14nYhH0wcdCpgOoC39Gbzvn6cZ7O9n+bwc02yKD9FTnJ7/TSrBcfebmolFZp1Rcicr9xbT0a5HUbigS7g=="
},
"chokidar": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.0.tgz",
@ -3588,7 +3626,6 @@
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"requires": {
"color-name": "1.1.3"
}
@ -3596,8 +3633,7 @@
"color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
"dev": true
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
},
"color-string": {
"version": "1.5.3",
@ -8061,8 +8097,12 @@
"lodash": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
"dev": true
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
},
"lodash-es": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.15.tgz",
"integrity": "sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ=="
},
"lodash.clonedeep": {
"version": "4.5.0",
@ -8659,6 +8699,11 @@
"minimist": "^1.2.5"
}
},
"moment": {
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.27.0.tgz",
"integrity": "sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ=="
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@ -8739,6 +8784,16 @@
"integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==",
"dev": true
},
"ng2-charts": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/ng2-charts/-/ng2-charts-2.3.2.tgz",
"integrity": "sha512-T0rPivwZITKtEtFRVodRCO+kIczWIP6V4YLZvf6Kg1jqc8jYGZ37H5ywT0Q7N0Rt5dJGhC5z1/38nWFBVFx5iw==",
"requires": {
"@types/chart.js": "^2.7.48",
"lodash-es": "^4.17.11",
"tslib": "^1.9.0"
}
},
"nice-try": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
@ -10831,6 +10886,11 @@
"integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA==",
"dev": true
},
"quick-score": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/quick-score/-/quick-score-0.0.8.tgz",
"integrity": "sha512-nCWx9FPiVvNeO8aUkrikrVL/v0XIGQSMQMBLLTXa/d64Wb/w8v8odSiuQAMPez20HdOngWPB8LcB8SfnIoE8bA=="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",

View File

@ -3,12 +3,14 @@
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve -o",
"build": "ng build",
"start": "ng serve",
"build": "ng build --prod --aot",
"build-push": "ng build --prod --aot && cf push",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e",
"coverage": "ng test --no-watch --code-coverage"
"coverage": "ng test --no-watch --code-coverage",
"api": "cd C:\\Users\\vle2fe\\Documents\\Code\\API && node dist\\index.js"
},
"private": true,
"dependencies": {
@ -21,9 +23,14 @@
"@angular/platform-browser-dynamic": "~9.1.7",
"@angular/router": "~9.1.7",
"@hapi/joi": "^17.1.1",
"@inst-iot/bosch-angular-ui-components": "^0.5.30",
"@inst-iot/bosch-angular-ui-components": "file:../Bosch-UI-Components/bosch-angular-ui-components/dist-lib/inst-iot-bosch-angular-ui-components-0.6.0.tgz",
"angular-2-local-storage": "^3.0.2",
"chart.js": "^2.9.3",
"chartjs-plugin-datalabels": "^0.7.0",
"flatpickr": "^4.6.3",
"lodash": "^4.17.15",
"ng2-charts": "^2.3.2",
"quick-score": "0.0.8",
"rxjs": "~6.5.5",
"tslib": "^1.10.0",
"zone.js": "~0.10.2"

4
src/Staticfile Normal file
View File

@ -0,0 +1,4 @@
pushstate: enabled
force_https: true
root: UI
location_include: custom-header.conf

View File

@ -1,53 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { ApiService } from './api.service';
import {HttpClient} from '@angular/common/http';
import {LocalStorageService} from 'angular-2-local-storage';
import {Observable} from 'rxjs';
let apiService: ApiService;
let httpClientSpy: jasmine.SpyObj<HttpClient>;
let localStorageServiceSpy: jasmine.SpyObj<LocalStorageService>;
describe('ApiService', () => {
beforeEach(() => {
const httpSpy = jasmine.createSpyObj('HttpClient', ['get']);
const localStorageSpy = jasmine.createSpyObj('LocalStorageService', ['get']);
TestBed.configureTestingModule({
providers: [
ApiService,
{provide: HttpClient, useValue: httpSpy},
{provide: LocalStorageService, useValue: localStorageSpy}
]
});
apiService = TestBed.inject(ApiService);
httpClientSpy = TestBed.inject(HttpClient) as jasmine.SpyObj<HttpClient>;
localStorageServiceSpy = TestBed.inject(LocalStorageService) as jasmine.SpyObj<LocalStorageService>;
});
it('should be created', () => {
expect(apiService).toBeTruthy();
});
it('should do get requests without auth if not available', () => {
const getReturn = new Observable();
httpClientSpy.get.and.returnValue(getReturn);
localStorageServiceSpy.get.and.returnValue(undefined);
const result = apiService.get('/testurl');
expect(result).toBe(getReturn);
expect(httpClientSpy.get).toHaveBeenCalledWith('/testurl', {});
expect(localStorageServiceSpy.get).toHaveBeenCalledWith('basicAuth');
});
it('should do get requests with basic auth if available', () => {
const getReturn = new Observable();
httpClientSpy.get.and.returnValue(getReturn);
localStorageServiceSpy.get.and.returnValue('basicAuth');
const result = apiService.get('/testurl');
expect(result).toBe(getReturn);
expect(httpClientSpy.get).toHaveBeenCalledWith('/testurl', jasmine.any(Object)); // could not test http headers better
expect(localStorageServiceSpy.get).toHaveBeenCalledWith('basicAuth');
});
});

View File

@ -1,30 +0,0 @@
import { Injectable } from '@angular/core';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {LocalStorageService} from 'angular-2-local-storage';
@Injectable({
providedIn: 'root'
})
export class ApiService {
private host = '/api';
constructor(
private http: HttpClient,
private storage: LocalStorageService
) { }
get(url) {
return this.http.get(this.host + url, this.authOptions());
}
private authOptions() {
const auth = this.storage.get('basicAuth');
if (auth) {
return {headers: new HttpHeaders({Authorization: 'Basic ' + auth})};
}
else {
return {};
}
}
}

View File

@ -1,15 +1,19 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import {HomeComponent} from './home/home.component';
import {LoginService} from './login.service';
import {LoginService} from './services/login.service';
import {SampleComponent} from './sample/sample.component';
import {SamplesComponent} from './samples/samples.component';
import {DocumentationComponent} from './documentation/documentation.component';
const routes: Routes = [
{path: '', component: HomeComponent},
{path: 'home', component: HomeComponent},
{path: 'samples', component: SamplesComponent},
{path: 'replace-me', component: HomeComponent, canActivate: [LoginService]},
{path: 'samples', component: SamplesComponent, canActivate: [LoginService]},
{path: 'samples/new', component: SampleComponent, canActivate: [LoginService]},
{path: 'samples/edit/:id', component: SampleComponent, canActivate: [LoginService]},
{path: 'documentation', component: DocumentationComponent},
// if not authenticated
{ path: '**', redirectTo: '' }

View File

@ -1,9 +1,27 @@
<rb-full-header>
<nav *rbMainNavItems>
<a routerLink="/home" routerLinkActive="active" rbLoadingLink>Home</a>
<a routerLink="/samples" routerLinkActive="active" rbLoadingLink *ngIf="loginService.canActivate()">Samples</a>
<a routerLink="/samples" routerLinkActive="active" rbLoadingLink *ngIf="loginService.isLoggedIn">Samples</a>
<a routerLink="/documentation" routerLinkActive="active" rbLoadingLink>Documentation</a>
</nav>
<div *rbSubBrandHeader>Digital Fingerprint of Plastics</div>
<ng-container *ngIf="loginService.isLoggedIn">
<nav *rbActionNavItems>
<a href="javascript:" [rbPopover]="userPopover" [anchor]="popoverAnchor">
{{loginService.username}} <span class="rb-ic rb-ic-my-brand-frame" #popoverAnchor></span></a>
</nav>
<ng-template #userPopover>
<div class="spacing">
<p>
<!-- Some user specific information-->
</p>
<button type="button" class="rb-btn rb-primary" (click)="logout()">Logout</button>
</div>
</ng-template>
</ng-container>
<div *rbSubBrandHeader><span class="dev-label" *ngIf="devMode">DEVELOPMENT</span>Digital Fingerprint of Plastics</div>
</rb-full-header>
<div class="container">

View File

@ -0,0 +1,5 @@
.dev-label {
color: #F00;
font-size: 32px;
margin-right: 40px;
}

View File

@ -3,6 +3,9 @@ import { AppComponent } from './app.component';
import {By} from '@angular/platform-browser';
import {RbUiComponentsModule} from '@inst-iot/bosch-angular-ui-components';
import {RouterTestingModule} from '@angular/router/testing';
import {LoginService} from './services/login.service';
let loginServiceSpy: jasmine.SpyObj<LoginService>;
describe('AppComponent', () => {
let component: AppComponent;
@ -10,13 +13,19 @@ describe('AppComponent', () => {
let css; // get native element by css selector
beforeEach(async(() => {
const loginSpy = jasmine.createSpyObj('LoginService', ['login', 'canActivate']);
TestBed.configureTestingModule({
declarations: [ AppComponent ],
imports: [
RbUiComponentsModule,
RouterTestingModule
],
providers: [
{provide: LoginService, useValue: loginSpy}
]
}).compileComponents();
loginServiceSpy = TestBed.inject(LoginService) as jasmine.SpyObj<LoginService>;
}));
beforeEach(() => {

View File

@ -1,5 +1,13 @@
import { Component } from '@angular/core';
import {LoginService} from './login.service';
import { Component, isDevMode} from '@angular/core';
import {LoginService} from './services/login.service';
import {Router} from '@angular/router';
// TODO: add multiple samples at once
// TODO: guess properties from material name
// TODO: validation: VZ, Humidity: min/max value, DPT: filename
// TODO: filter by not completely filled/no measurements
// TODO: account
// TODO: admin user handling, template pages, validation of samples
@Component({
selector: 'app-root',
@ -9,7 +17,17 @@ import {LoginService} from './login.service';
export class AppComponent {
constructor(
public loginService: LoginService
public loginService: LoginService,
private router: Router
) {
}
get devMode() {
return isDevMode();
}
logout() {
this.loginService.logout();
this.router.navigate(['/']);
}
}

View File

@ -1,23 +1,40 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { ChartsModule } from 'ng2-charts';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import {RbUiComponentsModule} from '@inst-iot/bosch-angular-ui-components';
import { LoginComponent } from './login/login.component';
import {FormFieldsModule, ModalService, RbUiComponentsModule} from '@inst-iot/bosch-angular-ui-components';
import {LoginComponent} from './login/login.component';
import { HomeComponent } from './home/home.component';
import {FormsModule} from '@angular/forms';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {LocalStorageModule} from 'angular-2-local-storage';
import {HttpClientModule} from '@angular/common/http';
import { SamplesComponent } from './samples/samples.component';
import {RbTableModule} from './rb-table/rb-table.module';
import { SampleComponent } from './sample/sample.component';
import { ValidateDirective } from './validate.directive';
import {CommonModule} from '@angular/common';
import { ErrorComponent } from './error/error.component';
import { ObjectPipe } from './object.pipe';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import { DocumentationComponent } from './documentation/documentation.component';
import { ImgMagnifierComponent } from './img-magnifier/img-magnifier.component';
import { ExistsPipe } from './exists.pipe';
@NgModule({
declarations: [
AppComponent,
LoginComponent,
HomeComponent,
SamplesComponent
SamplesComponent,
SampleComponent,
ValidateDirective,
ErrorComponent,
ObjectPipe,
DocumentationComponent,
ImgMagnifierComponent,
ExistsPipe
],
imports: [
LocalStorageModule.forRoot({
@ -25,13 +42,21 @@ import {RbTableModule} from './rb-table/rb-table.module';
storageType: 'localStorage'
}),
BrowserModule,
BrowserAnimationsModule,
AppRoutingModule,
RbUiComponentsModule,
FormsModule,
HttpClientModule,
RbTableModule
RbTableModule,
ReactiveFormsModule,
FormFieldsModule,
CommonModule,
ChartsModule
],
providers: [
ModalService,
{ provide: Window, useValue: window }
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

View File

@ -0,0 +1,9 @@
<h2>Samples</h2>
<p>
Find the API documentation here: <a href="https://definma-api.apps.de1.bosch-iot-cloud.com/api-doc/">https://definma-api.apps.de1.bosch-iot-cloud.com/api-doc/</a>
</p>
<h4>Database model</h4>
<app-img-magnifier src="assets/imgs/db_structure_latest.svg" zoom="2" [magnifierSize]="{width: 400, height: 300}" id="db-structure"></app-img-magnifier>

View File

@ -0,0 +1,7 @@
p {
margin-bottom: 20px;
}
img#db-structure {
width: 100%;
}

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { DocumentationComponent } from './documentation.component';
describe('DocumentationComponent', () => {
let component: DocumentationComponent;
let fixture: ComponentFixture<DocumentationComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ DocumentationComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DocumentationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-documentation',
templateUrl: './documentation.component.html',
styleUrls: ['./documentation.component.scss']
})
export class DocumentationComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}

View File

@ -0,0 +1,3 @@
<rb-alert alertTitle="Error" type="error" okBtnLabel="Discard">
{{message}}
</rb-alert>

View File

View File

@ -0,0 +1,45 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ErrorComponent } from './error.component';
import {ModalService, RbUiComponentsModule} from '@inst-iot/bosch-angular-ui-components';
import {By} from '@angular/platform-browser';
describe('ErrorComponent', () => {
let component: ErrorComponent;
let fixture: ComponentFixture<ErrorComponent>;
let css; // get native element by css selector
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ ErrorComponent ],
imports: [
RbUiComponentsModule,
],
providers: [
ModalService
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ErrorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
css = (selector) => fixture.debugElement.query(By.css(selector)).nativeElement;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should show the alert', () => {
expect(css('rb-alert')).toBeTruthy();
});
it('should have the right message', () => {
component.message = 'test';
fixture.detectChanges();
expect(css('.dialog-text').innerText).toBe('test');
});
});

View File

@ -0,0 +1,17 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-error',
templateUrl: './error.component.html',
styleUrls: ['./error.component.scss']
})
export class ErrorComponent implements OnInit {
message = '';
constructor() { }
ngOnInit(): void {
}
}

View File

@ -0,0 +1,8 @@
import { ExistsPipe } from './exists.pipe';
describe('ExistsPipe', () => {
it('create an instance', () => {
const pipe = new ExistsPipe();
expect(pipe).toBeTruthy();
});
});

14
src/app/exists.pipe.ts Normal file
View File

@ -0,0 +1,14 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'exists',
pure: true
})
export class ExistsPipe implements PipeTransform {
transform(value: unknown, key?): unknown {
// console.log(new Date().getTime());
return value || value === 0 ? (key ? value[key] : value) : '';
}
}

View File

@ -0,0 +1,17 @@
<div class="img-container">
<div class="magnifier"
[ngStyle]="{
'background-size': backgroundSize,
'background-image': 'url(\'' + src + '\')',
'background-position': '-' + magnifierPos.x * zoom + 'px -' + magnifierPos.y * zoom + 'px',
left: magnifierPos.x + 'px',
top: magnifierPos.y + 'px',
width: magnifierSize.width + 'px',
height: magnifierSize.height + 'px'
}"
(mousemove)="calcPos($event)"
(mouseleave)="showMagnifier = false"
*ngIf="showMagnifier">
</div>
<img [src]="src" alt="" (mousemove)="calcPos($event)" (mouseenter)="showMagnifier = true" #mainImg>
</div>

View File

@ -0,0 +1,20 @@
@import "~@inst-iot/bosch-angular-ui-components/styles/variables/colors";
.img-container {
position:relative;
overflow: hidden;
& > img {
width: 100%;
}
}
.magnifier {
position: absolute;
background: #FFF;
background-repeat: no-repeat;
background-position: -500px -500px;
z-index: 99;
border: 1px solid #FFF;
box-shadow: 10px 10px 25px $color-bosch-light-gray-b25;
}

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ImgMagnifierComponent } from './img-magnifier.component';
describe('ImgMagnifierComponent', () => {
let component: ImgMagnifierComponent;
let fixture: ComponentFixture<ImgMagnifierComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ ImgMagnifierComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ImgMagnifierComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,41 @@
import {AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core';
@Component({
selector: 'app-img-magnifier',
templateUrl: './img-magnifier.component.html',
styleUrls: ['./img-magnifier.component.scss']
})
export class ImgMagnifierComponent implements OnInit, AfterViewInit {
@Input('src') src: string;
@Input('zoom') zoom: number;
@Input('magnifierSize') magnifierSize: {width: number, height: number};
@ViewChild('mainImg') mainImg: ElementRef;
backgroundSize;
magnifierPos = {x: 0, y: 0};
showMagnifier = false;
constructor(
private window: Window
) { }
ngOnInit(): void {
}
ngAfterViewInit() {
setTimeout(() => {
this.calcBackgroundSize();
}, 1);
}
calcPos(event) {
const img = this.mainImg.nativeElement.getBoundingClientRect();
this.magnifierPos.x = Math.min(img.width - this.magnifierSize.width, Math.max(0, event.pageX - img.left - this.window.pageXOffset - this.magnifierSize.width / 2));
this.magnifierPos.y = Math.min(img.height - this.magnifierSize.height + 7, Math.max(0, event.pageY - img.top - this.window.pageYOffset - this.magnifierSize.height / 2));
}
calcBackgroundSize() {
this.backgroundSize = this.mainImg ? (this.mainImg.nativeElement.width * this.zoom - this.magnifierSize.width) + 'px ' + (this.mainImg.nativeElement.height * this.zoom - this.magnifierSize.height) + 'px ' : '0 0';
}
}

View File

@ -1,10 +1,15 @@
<div class="login-wrapper">
<h2>Please log in</h2>
<form>
<rb-form-input name="username" label="username" [(ngModel)]="username"></rb-form-input>
<rb-form-input type="password" name="password" label="password" [(ngModel)]="password"></rb-form-input>
<form #loginForm="ngForm">
<rb-form-input name="username" label="username" appValidate="username" required [(ngModel)]="username" #usernameInput="ngModel">
<ng-template rbFormValidationMessage="failure">{{usernameInput.errors.failure}}</ng-template>
</rb-form-input>
<rb-form-input type="password" name="password" label="password" appValidate="password" required [(ngModel)]="password" #passwordInput="ngModel">
<ng-template rbFormValidationMessage="failure">{{passwordInput.errors.failure}}</ng-template>
</rb-form-input>
<button class="rb-btn rb-primary login-button" (click)="login()" type="submit" [disabled]="!loginForm.form.valid">Login</button>
<span class="message">{{message}}</span>
<button class="rb-btn rb-primary login-button" (click)="login()" type="submit">Login</button>
</form>
</div>

View File

@ -8,5 +8,5 @@
}
.login-button {
display: block;
margin-right: 10px;
}

View File

@ -1,10 +1,11 @@
import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import { LoginComponent } from './login.component';
import {LoginService} from '../login.service';
import {ValidationService} from '../validation.service';
import {RbUiComponentsModule} from '@inst-iot/bosch-angular-ui-components';
import {LoginService} from '../services/login.service';
import {ValidationService} from '../services/validation.service';
import {FormsModule} from '@angular/forms';
import {By} from '@angular/platform-browser';
import {ValidateDirective} from '../validate.directive';
import {RbUiComponentsModule} from '@inst-iot/bosch-angular-ui-components';
let validationServiceSpy: jasmine.SpyObj<ValidationService>;
let loginServiceSpy: jasmine.SpyObj<LoginService>;
@ -20,7 +21,7 @@ describe('LoginComponent', () => {
const loginSpy = jasmine.createSpyObj('LoginService', ['login']);
TestBed.configureTestingModule({
declarations: [ LoginComponent ],
declarations: [ LoginComponent, ValidateDirective ],
imports: [
RbUiComponentsModule,
FormsModule
@ -39,12 +40,15 @@ describe('LoginComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
component.ngOnInit();
fixture.detectChanges();
cssd = (selector) => fixture.debugElement.query(By.css(selector));
css = (selector) => fixture.debugElement.query(By.css(selector)).nativeElement;
});
it('should create', () => {
validationServiceSpy.username.and.returnValue({ok: true, error: ''});
validationServiceSpy.password.and.returnValue({ok: true, error: ''});
expect(component).toBeTruthy();
});
@ -61,27 +65,45 @@ describe('LoginComponent', () => {
expect(css('.message').innerText).toBe('');
});
it('should have a login button', () => {
expect(css('.login-button')).toBeTruthy();
});
it('should display a message when a wrong username was entered', () => {
validationServiceSpy.username.and.returnValue({ok: false, error: 'username must only contain a-z0-9-_.'});
validationServiceSpy.password.and.returnValue({ok: true, error: ''});
cssd('.login-button').triggerEventHandler('click', null);
fixture.detectChanges();
expect(css('.message').innerText).toBe('username must only contain a-z0-9-_.');
});
it('should display a message when a wrong password was entered', () => {
it('should have a login button', async () => {
validationServiceSpy.username.and.returnValue({ok: true, error: ''});
validationServiceSpy.password.and.returnValue({ok: true, error: ''});
await fixture.whenStable();
fixture.detectChanges();
expect(css('.login-button')).toBeTruthy();
expect(css('.login-button').disabled).toBeTruthy();
});
it('should reject a wrong username', async () => {
validationServiceSpy.username.and.returnValue({ok: false, error: 'username must only contain a-z0-9-_.'});
component.username = 'ab#';
fixture.detectChanges();
await fixture.whenRenderingDone();
expect(component.loginForm.controls.username.valid).toBeFalsy();
expect(validationServiceSpy.username).toHaveBeenCalledWith('ab#');
});
it('should reject a wrong password', async () => {
validationServiceSpy.password.and.returnValue({ok: false, error: 'password must only contain a-zA-Z0-9!"#%&\'()*+,-./:;<=>?@[]^_`{|}~'});
component.password = 'abc';
fixture.detectChanges();
await fixture.whenRenderingDone();
expect(component.loginForm.controls.password.valid).toBeFalsy();
expect(validationServiceSpy.password).toHaveBeenCalledWith('abc');
});
it('should enable the login button with valid credentials', async () => {
validationServiceSpy.username.and.returnValue({ok: true, error: ''});
validationServiceSpy.password.and.returnValue({ok: true, error: ''});
loginServiceSpy.login.and.returnValue(new Promise(r => r(true)));
fixture.detectChanges();
await fixture.whenRenderingDone();
cssd('.login-button').triggerEventHandler('click', null);
fixture.detectChanges();
expect(css('.message').innerText).toBe('password must only contain a-zA-Z0-9!"#%&\'()*+,-./:;<=>?@[]^_`{|}~');
expect(loginServiceSpy.login).not.toHaveBeenCalled();
expect(css('.login-button').disabled).toBeFalsy();
expect(loginServiceSpy.login.calls.count()).toBe(1);
});
it('should call the LoginService with valid credentials', () => {
@ -102,6 +124,6 @@ describe('LoginComponent', () => {
expect(loginServiceSpy.login.calls.count()).toBe(1);
tick();
fixture.detectChanges();
expect(css('.message').innerText).toBe('Wrong credentials! Try again.');
expect(css('.message').innerText).toBe('Wrong credentials!');
}));
});

View File

@ -1,6 +1,8 @@
import { Component, OnInit } from '@angular/core';
import {ValidationService} from '../validation.service';
import {LoginService} from '../login.service';
import {Component, OnInit, ViewChild} from '@angular/core';
import {ValidationService} from '../services/validation.service';
import {LoginService} from '../services/login.service';
import {Router} from '@angular/router';
@Component({
selector: 'app-login',
@ -9,33 +11,30 @@ import {LoginService} from '../login.service';
})
export class LoginComponent implements OnInit {
message = ''; // message below login fields
username = ''; // credentials
password = '';
validCredentials = false; // true if entered credentials are valid
message = ''; // message below login fields
@ViewChild('loginForm') loginForm;
constructor(
private validate: ValidationService,
private loginService: LoginService
private loginService: LoginService,
private router: Router
) { }
ngOnInit() {
}
login() {
const {ok: userOk, error: userError} = this.validate.username(this.username);
const {ok: passwordOk, error: passwordError} = this.validate.password(this.password);
this.message = userError + (userError !== '' && passwordError !== '' ? '\n' : '') + passwordError; // display errors
if (userOk && passwordOk) {
this.loginService.login(this.username, this.password).then(ok => {
if (ok) {
this.message = 'Login successful'; // TODO: think about following action
this.message = 'Login successful';
this.router.navigate(['/samples']);
}
else {
this.message = 'Wrong credentials! Try again.';
this.message = 'Wrong credentials!';
}
});
}
}
}

View File

@ -0,0 +1,7 @@
import { BaseModel } from './base.model';
describe('BaseModel', () => {
it('should create an instance', () => {
expect(new BaseModel()).toBeTruthy();
});
});

View File

@ -0,0 +1,10 @@
export class BaseModel {
deserialize(input: any): this {
Object.assign(this, input);
return this;
}
sendFormat(): this {
return this;
}
}

View File

@ -0,0 +1,7 @@
import { CustomFieldsModel } from './custom-fields.model';
describe('CustomFieldsModel', () => {
it('should create an instance', () => {
expect(new CustomFieldsModel()).toBeTruthy();
});
});

View File

@ -0,0 +1,7 @@
import {BaseModel} from './base.model';
export class CustomFieldsModel extends BaseModel {
name = '';
qty = 0;
}

View File

@ -0,0 +1 @@
export type IdModel = string | null;

View File

@ -0,0 +1,7 @@
import { MaterialModel } from './material.model';
describe('MaterialModel', () => {
it('should create an instance', () => {
expect(new MaterialModel()).toBeTruthy();
});
});

View File

@ -0,0 +1,16 @@
import _ from 'lodash';
import {IdModel} from './id.model';
import {BaseModel} from './base.model';
export class MaterialModel extends BaseModel {
_id: IdModel = null;
name = '';
supplier = '';
group = '';
properties: {material_template: string, [prop: string]: string} = {material_template: null};
numbers: string[] = [''];
sendFormat() {
return _.pick(this, ['name', 'supplier', 'group', 'numbers', 'properties']);
}
}

View File

@ -0,0 +1,7 @@
import { MeasurementModel } from './measurement.model';
describe('MeasurementModel', () => {
it('should create an instance', () => {
expect(new MeasurementModel()).toBeTruthy();
});
});

View File

@ -0,0 +1,29 @@
import _ from 'lodash';
import {IdModel} from './id.model';
import {BaseModel} from './base.model';
export class MeasurementModel extends BaseModel {
_id: IdModel = null;
sample_id: IdModel = null;
measurement_template: IdModel;
values: {[prop: string]: any} = {};
constructor(measurementTemplate: IdModel = null) {
super();
this.measurement_template = measurementTemplate;
}
deserialize(input: any): this {
Object.assign(this, input);
Object.keys(this.values).forEach(key => {
if (this.values[key] === null) {
this.values[key] = '';
}
});
return this;
}
sendFormat(omit = []) {
return _.omit(_.pick(this, ['sample_id', 'measurement_template', 'values']), omit);
}
}

View File

@ -0,0 +1,7 @@
import { SampleModel } from './sample.model';
describe('SampleModel', () => {
it('should create an instance', () => {
expect(new SampleModel()).toBeTruthy();
});
});

View File

@ -0,0 +1,36 @@
import _ from 'lodash';
import {IdModel} from './id.model';
import {MaterialModel} from './material.model';
import {MeasurementModel} from './measurement.model';
import {BaseModel} from './base.model';
export class SampleModel extends BaseModel {
_id: IdModel = null;
color = '';
number = '';
type = '';
batch = '';
condition: {condition_template: string, [prop: string]: string} | {} = {};
material_id: IdModel = null;
material: MaterialModel;
measurements: MeasurementModel[] = [];
note_id: IdModel = null;
user_id: IdModel = null;
notes: {comment: string, sample_references: {sample_id: IdModel, relation: string}[], custom_fields: {[prop: string]: string}} = {comment: '', sample_references: [], custom_fields: {}};
deserialize(input: any): this {
Object.assign(this, input);
if (input.hasOwnProperty('material')) {
this.material = new MaterialModel().deserialize(input.material);
this.material_id = input.material._id;
}
if (input.hasOwnProperty('measurements')) {
this.measurements = input.measurements.map(e => new MeasurementModel().deserialize(e));
}
return this;
}
sendFormat() {
return _.pick(this, ['color', 'type', 'batch', 'condition', 'material_id', 'notes']);
}
}

View File

@ -0,0 +1,7 @@
import { TemplateModel } from './template.model';
describe('TemplateModel', () => {
it('should create an instance', () => {
expect(new TemplateModel()).toBeTruthy();
});
});

View File

@ -0,0 +1,9 @@
import {IdModel} from './id.model';
import {BaseModel} from './base.model';
export class TemplateModel extends BaseModel {
_id: IdModel = null;
name = '';
version = 1;
parameters: {name: string, range: {[prop: string]: any}}[] = [];
}

View File

@ -0,0 +1,8 @@
import { ObjectPipe } from './object.pipe';
describe('ObjectPipe', () => {
it('create an instance', () => {
const pipe = new ObjectPipe();
expect(pipe).toBeTruthy();
});
});

13
src/app/object.pipe.ts Normal file
View File

@ -0,0 +1,13 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'object',
pure: true
})
export class ObjectPipe implements PipeTransform {
transform(value: object): string {
return value ? JSON.stringify(value) : '';
}
}

View File

@ -1,3 +1,6 @@
<table>
<script src="rb-table.component.ts"></script>
<div class="table-wrapper">
<table>
<ng-content></ng-content>
</table>
</table>
</div>

View File

@ -1,5 +1,16 @@
@import "~@inst-iot/bosch-angular-ui-components/styles/variables/colors";
.table-wrapper {
overflow-x: auto;
width: 100%;
&, & > table { // scrollbar at the top
transform:rotateX(180deg);
-ms-transform:rotateX(180deg); /* IE 9 */
-webkit-transform:rotateX(180deg); /* Safari and Chrome */
}
}
table {
width: 100%;
border-collapse: collapse;
@ -9,6 +20,10 @@ table {
::ng-deep td, ::ng-deep th {
padding: 8px 5px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 200px;
}
::ng-deep th {

View File

@ -0,0 +1,159 @@
<h2>{{new ? 'Add new sample' : 'Edit sample ' + sample.number}}</h2>
<rb-loading-spinner *ngIf="loading"></rb-loading-spinner>
<form #sampleForm="ngForm" *ngIf="!responseData && !loading">
<!--<form #sampleForm="ngForm">-->
<div class="sample">
<div>
<rb-form-input name="materialname" label="material name" [rbFormInputAutocomplete]="autocomplete.bind(this, materialNames)" [rbDebounceTime]="0" [rbInitialOpen]="true" (keydown)="preventDefault($event)" (ngModelChange)="findMaterial($event)" appValidate="stringOf" [appValidateArgs]="[materialNames]" required [(ngModel)]="material.name" [autofocus]="true" #materialNameInput="ngModel">
<ng-template rbFormValidationMessage="required">Cannot be empty</ng-template>
<ng-template rbFormValidationMessage="failure">Unknown material, add properties for new material</ng-template>
</rb-form-input>
<button class="rb-btn rb-secondary" type="button" (click)="setNewMaterial(!newMaterial)"><span class="rb-ic rb-ic-add"></span>&nbsp;New material</button>
</div>
<div class="material shaded-container" *ngIf="newMaterial" [@inOut]>
<h4>Material properties</h4>
<rb-form-input name="supplier" label="supplier" [rbFormInputAutocomplete]="autocomplete.bind(this, suppliers)" [rbDebounceTime]="0" [rbInitialOpen]="true" appValidate="string" required [(ngModel)]="material.supplier" #supplierInput="ngModel">
<ng-template rbFormValidationMessage="failure">{{supplierInput.errors.failure}}</ng-template>
</rb-form-input>
<rb-form-input name="group" label="group" [rbFormInputAutocomplete]="autocomplete.bind(this, groups)" [rbDebounceTime]="0" [rbInitialOpen]="true" appValidate="string" required [(ngModel)]="material.group" #groupInput="ngModel">
<ng-template rbFormValidationMessage="failure">{{groupInput.errors.failure}}</ng-template>
</rb-form-input>
<div class="material-numbers">
<rb-form-input *ngFor="let ignore of [].constructor(material.numbers.length); index as i" label="material number" appValidate="string" [name]="'material.number-' + i" (keyup)="handleMaterialNumbers()" [(ngModel)]="material.numbers[i]" ngModel></rb-form-input>
</div>
<rb-form-select name="conditionSelect" label="Condition" (ngModelChange)="selectMaterialTemplate($event)" [ngModel]="material.properties.material_template">
<option *ngFor="let m of materialTemplates" [value]="m._id">{{m.name}}</option>
</rb-form-select>
<rb-form-input *ngFor="let parameter of materialTemplate.parameters; index as i" [name]="'materialParameter' + i" [label]="parameter.name" appValidate="string" required [(ngModel)]="material.properties[parameter.name]" #parameterInput="ngModel">
<ng-template rbFormValidationMessage="failure">{{parameterInput.errors.failure}}</ng-template>
<ng-template rbFormValidationMessage="required">Cannot be empty</ng-template>
</rb-form-input>
</div>
&nbsp;
<div>
<rb-form-input name="type" label="type" appValidate="string" required [(ngModel)]="sample.type" #typeInput="ngModel">
<ng-template rbFormValidationMessage="failure">{{typeInput.errors.failure}}</ng-template>
<ng-template rbFormValidationMessage="required">Cannot be empty</ng-template>
</rb-form-input>
<rb-form-input name="color" label="color" appValidate="string" required [(ngModel)]="sample.color" #colorInput="ngModel">
<ng-template rbFormValidationMessage="failure">{{colorInput.errors.failure}}</ng-template>
<ng-template rbFormValidationMessage="required">Cannot be empty</ng-template>
</rb-form-input>
<rb-form-input name="batch" label="batch" appValidate="string" [(ngModel)]="sample.batch" #batchInput="ngModel">
<ng-template rbFormValidationMessage="failure">{{batchInput.errors.failure}}</ng-template>
</rb-form-input>
</div>
</div>
<div class="notes">
<rb-form-input name="comment" label="comment" appValidate="stringLength" [appValidateArgs]="[512]" [(ngModel)]="sample.notes.comment" #commentInput="ngModel">
<ng-template rbFormValidationMessage="failure">{{commentInput.errors.failure}}</ng-template>
</rb-form-input>
<h5>Sample references</h5>
<div *ngFor="let reference of sampleReferences; index as i" class="two-col" [@inOut]>
<div>
<rb-form-input [name]="'sr-id' + i" label="sample number" [rbFormInputAutocomplete]="sampleReferenceListBind()" [rbDebounceTime]="300" appValidate="stringOf" [appValidateArgs]="[sampleReferenceAutocomplete[i]]" (ngModelChange)="checkSampleReference($event, i)" [ngModel]="reference[0]" #idInput="ngModel">
<ng-template rbFormValidationMessage="failure">Unknown sample number</ng-template>
</rb-form-input>
</div>
<rb-form-input [name]="'sr-relation' + i" label="relation" appValidate="string" [required]="reference[0] !== ''" [(ngModel)]="reference[1]">
<ng-template rbFormValidationMessage="required">Cannot be empty</ng-template>
</rb-form-input>
</div>
<h5>Additional properties</h5>
<div *ngFor="let field of customFields; index as i" class="two-col" [@inOut]>
<div>
<rb-form-input [name]="'cf-key' + i" label="key" [rbFormInputAutocomplete]="autocomplete.bind(this, availableCustomFields)" [rbDebounceTime]="0" [rbInitialOpen]="true" appValidate="unique" [appValidateArgs]="[uniqueCfValues(i)]" (ngModelChange)="adjustCustomFields($event, i)" [ngModel]="field[0]" #keyInput="ngModel">
<ng-template rbFormValidationMessage="failure">{{keyInput.errors.failure}}</ng-template>
</rb-form-input>
</div>
<rb-form-input [name]="'cf-value' + i" label="value" appValidate="string" [required]="field[0] !== ''" [(ngModel)]="field[1]">
<ng-template rbFormValidationMessage="required">Cannot be empty</ng-template>
</rb-form-input>
</div>
</div>
&nbsp;
<div class="conditions shaded-container">
<h4>
Condition
<button class="rb-btn rb-secondary condition-set" type="button" (click)="toggleCondition()" [disabled]="!conditionTemplates">{{condition ? 'Do not set condition' : 'Set condition'}}</button>
</h4>
<div *ngIf="condition" [@inOut]>
<rb-form-select name="conditionSelect" label="Condition" (ngModelChange)="selectCondition($event)" [ngModel]="condition._id">
<option *ngFor="let c of conditionTemplates" [value]="c._id">{{c.name}}</option>
</rb-form-select>
<rb-form-input *ngFor="let parameter of condition.parameters; index as i" [name]="'conditionParameter' + i" [label]="parameter.name" appValidate="string" required [(ngModel)]="sample.condition[parameter.name]" #parameterInput="ngModel">
<ng-template rbFormValidationMessage="failure">{{parameterInput.errors.failure}}</ng-template>
<ng-template rbFormValidationMessage="required">Cannot be empty</ng-template>
</rb-form-input>
</div>
</div>
&nbsp;
<div class="measurements shaded-container">
<h4>Measurements</h4>
<div *ngFor="let measurement of sample.measurements; index as mIndex" [@inOut]>
<rb-form-select name="measurementTemplateSelect" label="Template" [(ngModel)]="measurement.measurement_template" (ngModelChange)="clearChart(mIndex)">
<option *ngFor="let m of measurementTemplates" [value]="m._id">{{m.name}}</option>
</rb-form-select>
<div *ngFor="let parameter of getMeasurementTemplate(measurement.measurement_template).parameters; index as pIndex">
<rb-form-input *ngIf="!parameter.range.type" [name]="'measurementParameter' + mIndex + '-' + pIndex" [label]="parameter.name" appValidate="string" [(ngModel)]="measurement.values[parameter.name]" #parameterInput="ngModel">
<ng-template rbFormValidationMessage="failure">{{parameterInput.errors.failure}}</ng-template>
<ng-template rbFormValidationMessage="required">Cannot be empty</ng-template>
</rb-form-input>
<rb-form-file *ngIf="parameter.range.type" [name]="'measurementParameter' + mIndex + '-' + pIndex" [label]="parameter.name" maxSize="10000000" (ngModelChange)="fileToArray($event, mIndex, parameter.name)" placeholder="Select file or drag and drop" dragDrop required ngModel>
<ng-template rbFormValidationMessage="required">Cannot be empty</ng-template>
</rb-form-file>
<canvas baseChart *ngIf="parameter.range.type && charts[mIndex][0].data.length > 0" class="dpt-chart" [@inOut]
[datasets]="charts[mIndex]"
[labels]="[]"
[options]="chartOptions"
[legend]="false"
chartType="scatter">
</canvas>
</div>
<button class="rb-btn rb-danger" type="button" (click)="removeMeasurement(mIndex)"><span class="rb-ic rb-ic-delete"></span>&nbsp;Delete measurement</button>
</div>
&nbsp;
<div>
<button class="rb-btn rb-secondary" type="button" (click)="addMeasurement()" [disabled]="!measurementTemplates"><span class="rb-ic rb-ic-add"></span>&nbsp;New measurement</button>
</div>
</div>
<div>
<button class="rb-btn rb-primary" type="submit" (click)="saveSample()" [disabled]="!sampleForm.form.valid">Save sample</button>
</div>
</form>
<div *ngIf="responseData">
<h3>Successfully added sample:</h3>
<rb-table id="response-data">
<tr><td>Sample number</td><td>{{responseData.number}}</td></tr>
<tr><td>Type</td><td>{{responseData.type}}</td></tr>
<tr><td>color</td><td>{{responseData.color}}</td></tr>
<tr><td>Batch</td><td>{{responseData.batch}}</td></tr>
<tr><td>Material</td><td>{{material.name}}</td></tr>
</rb-table>
&nbsp;
<div>
<a routerLink="/samples">
<button class="rb-btn rb-primary" type="button">Return to samples</button>
</a>
</div>
</div>

View File

@ -0,0 +1,21 @@
::ng-deep rb-table#response-data > table {
width: auto !important;
}
td:first-child {
font-weight: bold;
}
.condition-set {
float: right;
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
grid-column-gap: 10px;
}
.dpt-chart {
max-width: 400px;
}

View File

@ -0,0 +1,27 @@
// import { async, ComponentFixture, TestBed } from '@angular/core/testing';
//
// import { SampleComponent } from './sample.component';
//
// // TODO
//
// describe('SampleComponent', () => {
// let component: SampleComponent;
// let fixture: ComponentFixture<SampleComponent>;
//
// beforeEach(async(() => {
// TestBed.configureTestingModule({
// declarations: [ SampleComponent ]
// })
// .compileComponents();
// }));
//
// beforeEach(() => {
// fixture = TestBed.createComponent(SampleComponent);
// component = fixture.componentInstance;
// fixture.detectChanges();
// });
//
// it('should create', () => {
// expect(component).toBeTruthy();
// });
// });

View File

@ -0,0 +1,494 @@
import _ from 'lodash';
import {
AfterContentChecked,
Component,
OnInit,
ViewChild
} from '@angular/core';
import {ActivatedRoute, Router} from '@angular/router';
import {AutocompleteService} from '../services/autocomplete.service';
import {ApiService} from '../services/api.service';
import {MaterialModel} from '../models/material.model';
import {SampleModel} from '../models/sample.model';
import {NgForm, Validators} from '@angular/forms';
import {ValidationService} from '../services/validation.service';
import {TemplateModel} from '../models/template.model';
import {MeasurementModel} from '../models/measurement.model';
import { ChartOptions } from 'chart.js';
import {animate, style, transition, trigger} from '@angular/animations';
import {Observable} from 'rxjs';
// TODO: tests
// TODO: confirmation for new group/supplier
// TODO: work on better recognition for file input
// TODO: only show condition (if not set) and measurements in edit sample dialog at first
// TODO: multiple spectra
// TODO: multiple samples for base data, extend multiple measurements, conditions
// TODO: material properties, color (in material and sample (not required))
// TODO: API $in Regex
@Component({
selector: 'app-sample',
templateUrl: './sample.component.html',
styleUrls: ['./sample.component.scss'],
animations: [
trigger(
'inOut', [
transition(':enter', [
style({height: 0, opacity: 0}),
animate('0.5s ease-out', style({height: '*', opacity: 1}))
]),
transition(':leave', [
style({height: '*', opacity: 1}),
animate('0.5s ease-in', style({height: 0, opacity: 0}))
])
]
)
]
})
export class SampleComponent implements OnInit, AfterContentChecked {
@ViewChild('sampleForm') sampleForm: NgForm;
new; // true if new sample should be created
newMaterial = false; // true if new material should be created
materials: MaterialModel[] = []; // all materials
suppliers: string[] = []; // all suppliers
groups: string[] = []; // all groups
conditionTemplates: TemplateModel[]; // all conditions
condition: TemplateModel | null = null; // selected condition
materialTemplates: TemplateModel[]; // all material templates
materialTemplate: TemplateModel | null = null; // selected material template
materialNames = []; // names of all materials
material = new MaterialModel(); // object of current selected material
sample = new SampleModel();
customFields: [string, string][] = [['', '']];
sampleReferences: [string, string, string][] = [['', '', '']];
sampleReferenceFinds: {_id: string, number: string}[] = []; // raw sample reference data from db
currentSRIndex = 0; // index of last entered sample reference
availableCustomFields: string[] = [];
sampleReferenceAutocomplete: string[][] = [[]];
responseData: SampleModel; // gets filled with response data after saving the sample
measurementTemplates: TemplateModel[];
loading = 0; // number of currently loading instances
checkFormAfterInit = false;
charts = []; // chart data for spectrums
readonly chartInit = [{
data: [],
label: 'Spectrum',
showLine: true,
fill: false,
pointRadius: 0,
borderColor: '#00a8b0',
borderWidth: 2
}];
readonly chartOptions: ChartOptions = {
scales: {
xAxes: [{ticks: {min: 400, max: 4000, stepSize: 400, reverse: true}}],
yAxes: [{ticks: {min: 0, max: 1}}]
},
responsive: true,
tooltips: {enabled: false},
hover: {mode: null},
maintainAspectRatio: true,
plugins: {datalabels: {display: false}}
};
constructor(
private router: Router,
private route: ActivatedRoute,
private api: ApiService,
private validation: ValidationService,
public autocomplete: AutocompleteService
) { }
ngOnInit(): void {
this.new = this.router.url === '/samples/new';
this.loading = 7;
this.api.get<MaterialModel[]>('/materials?status=all', (data: any) => {
this.materials = data.map(e => new MaterialModel().deserialize(e));
this.materialNames = data.map(e => e.name);
this.loading--;
});
this.api.get<string[]>('/material/suppliers', (data: any) => {
this.suppliers = data;
this.loading--;
});
this.api.get<string[]>('/material/groups', (data: any) => {
this.groups = data;
this.loading--;
});
this.api.get<TemplateModel[]>('/template/conditions', data => {
this.conditionTemplates = data.map(e => new TemplateModel().deserialize(e));
this.loading--;
});
this.api.get<TemplateModel[]>('/template/materials', data => {
this.materialTemplates = data.map(e => new TemplateModel().deserialize(e));
this.selectMaterialTemplate(this.materialTemplates[0]._id);
this.loading--;
});
this.api.get<TemplateModel[]>('/template/measurements', data => {
this.measurementTemplates = data.map(e => new TemplateModel().deserialize(e));
if (!this.new) {
this.loading++;
this.api.get<SampleModel>('/sample/' + this.route.snapshot.paramMap.get('id'), sData => {
this.sample.deserialize(sData);
this.charts = [];
const spectrumTemplate = this.measurementTemplates.find(e => e.name === 'spectrum')._id;
let spectrumCounter = 0;
this.sample.measurements.forEach((measurement, i) => {
this.charts.push(_.cloneDeep(this.chartInit));
if (measurement.measurement_template === spectrumTemplate) {
setTimeout(() => {
this.generateChart(measurement.values.dpt, i);
console.log(this.charts);
}, spectrumCounter * 20);
spectrumCounter ++;
}
});
this.material = sData.material;
this.customFields = this.sample.notes.custom_fields && this.sample.notes.custom_fields !== {} ? Object.keys(this.sample.notes.custom_fields).map(e => [e, this.sample.notes.custom_fields[e]]) : [['', '']];
if (this.sample.notes.sample_references.length) {
this.sampleReferences = [];
this.sampleReferenceAutocomplete = [];
let loadCounter = this.sample.notes.sample_references.length;
this.sample.notes.sample_references.forEach(reference => {
this.api.get<SampleModel>('/sample/' + reference.sample_id, srData => {
this.sampleReferences.push([srData.number, reference.relation, reference.sample_id]);
this.sampleReferenceAutocomplete.push([srData.number]);
if (!--loadCounter) {
this.sampleReferences.push(['', '', '']);
this.sampleReferenceAutocomplete.push([]);
console.log(this.sampleReferences);
console.log(this.sampleReferenceAutocomplete);
}
});
});
}
if ('condition_template' in this.sample.condition) {
this.selectCondition(this.sample.condition.condition_template);
}
console.log('data loaded');
this.loading--;
this.checkFormAfterInit = true;
});
}
this.loading--;
});
this.api.get<TemplateModel[]>('/sample/notes/fields', data => {
this.availableCustomFields = data.map(e => e.name);
this.loading--;
});
}
ngAfterContentChecked() {
// attach validators to dynamic condition fields when all values are available and template was fully created
if (this.condition && this.condition.hasOwnProperty('parameters') && this.condition.parameters.length > 0 && this.condition.parameters[0].hasOwnProperty('range') && this.sampleForm && this.sampleForm.form.get('conditionParameter0')) {
for (const i in this.condition.parameters) {
if (this.condition.parameters[i]) {
this.attachValidator('conditionParameter' + i, this.condition.parameters[i].range, true);
}
}
}
// attach validators to dynamic material fields when all values are available and template was fully created
if (this.materialTemplate && this.materialTemplate.hasOwnProperty('parameters') && this.materialTemplate.parameters.length > 0 && this.materialTemplate.parameters[0].hasOwnProperty('range') && this.sampleForm && this.sampleForm.form.get('materialParameter0')) {
for (const i in this.materialTemplate.parameters) {
if (this.materialTemplate.parameters[i]) {
this.attachValidator('materialParameter' + i, this.materialTemplate.parameters[i].range, true);
}
}
}
if (this.sampleForm && this.sampleForm.form.get('measurementParameter0-0')) {
this.sample.measurements.forEach((measurement, mIndex) => {
const template = this.getMeasurementTemplate(measurement.measurement_template);
for (const i in template.parameters) {
if (template.parameters[i]) {
this.attachValidator('measurementParameter' + mIndex + '-' + i, template.parameters[i].range, false);
}
}
});
if (this.checkFormAfterInit) {
this.checkFormAfterInit = false;
this.initialValidate();
}
}
}
initialValidate() {
console.log('initVal');
Object.keys(this.sampleForm.form.controls).forEach(field => {
this.sampleForm.form.get(field).updateValueAndValidity();
});
}
attachValidator(name: string, range: {[prop: string]: any}, required: boolean) {
if (this.sampleForm.form.get(name)) {
const validators = [];
if (required) {
validators.push(Validators.required);
}
if (range.hasOwnProperty('values')) {
validators.push(this.validation.generate('stringOf', [range.values]));
}
else if (range.hasOwnProperty('min') && range.hasOwnProperty('max')) {
validators.push(this.validation.generate('minMax', [range.min, range.max]));
}
else if (range.hasOwnProperty('min')) {
validators.push(this.validation.generate('min', [range.min]));
}
else if (range.hasOwnProperty('max')) {
validators.push(this.validation.generate('max', [range.max]));
}
this.sampleForm.form.get(name).setValidators(validators);
}
}
saveSample() {
new Promise<void>(resolve => {
if (this.newMaterial) { // save material first if new one exists
this.material.numbers = this.material.numbers.filter(e => e !== '');
this.api.post<MaterialModel>('/material/new', this.material.sendFormat(), data => {
this.materials.push(data); // add material to data
this.material = data;
this.sample.material_id = data._id; // add new material id to sample data
resolve();
});
}
else {
resolve();
}
}).then(() => { // save sample
this.sample.notes.custom_fields = {};
this.customFields.forEach(element => {
if (element[0] !== '') {
this.sample.notes.custom_fields[element[0]] = element[1];
}
});
this.sample.notes.sample_references = this.sampleReferences.filter(e => e[0] && e[1] && e[2]).map(e => ({sample_id: e[2], relation: e[1]}));
new Promise<SampleModel>(resolve => {
if (this.new) {
this.api.post<SampleModel>('/sample/new', this.sample.sendFormat(), resolve);
}
else {
this.api.put<SampleModel>('/sample/' + this.sample._id, this.sample.sendFormat(), resolve);
}
}).then( data => {
this.responseData = new SampleModel().deserialize(data);
this.material = this.materials.find(e => e._id === this.responseData.material_id);
this.sample.measurements.forEach(measurement => {
if (Object.keys(measurement.values).map(e => measurement.values[e]).join('') !== '') {
Object.keys(measurement.values).forEach(key => {
measurement.values[key] = measurement.values[key] === '' ? null : measurement.values[key];
});
if (measurement._id === null) { // new measurement
measurement.sample_id = data._id;
this.api.post<MeasurementModel>('/measurement/new', measurement.sendFormat());
}
else { // update measurement
this.api.put<MeasurementModel>('/measurement/' + measurement._id, measurement.sendFormat(['sample_id', 'measurement_template']));
}
}
else if (measurement._id !== null) { // existing measurement was left empty to delete
this.api.delete('/measurement/' + measurement._id);
}
});
});
});
}
findMaterial(name) {
const res = this.materials.find(e => e.name === name); // search for match
if (res) {
this.material = _.cloneDeep(res);
this.sample.material_id = this.material._id;
}
else {
if (this.sample.material_id !== null) { // reset previous match
this.material = new MaterialModel();
}
this.sample.material_id = null;
}
this.setNewMaterial();
}
preventDefault(event) {
if (event.key && event.key === 'Enter' || event.type === 'dragover') {
event.preventDefault();
}
}
// TODO: rework later
setNewMaterial(value = null) {
if (value === null) {
this.newMaterial = !this.sample.material_id;
}
else if (value || (!value && this.sample.material_id !== null )) { // set to false only if material already exists
this.newMaterial = value;
}
if (this.newMaterial) {
this.sampleForm.form.get('materialname').setValidators([Validators.required]);
}
else {
this.sampleForm.form.get('materialname').setValidators([Validators.required, this.validation.generate('stringOf', [this.materialNames])]);
}
this.sampleForm.form.get('materialname').updateValueAndValidity();
}
handleMaterialNumbers() {
const fieldNo = this.material.numbers.length;
const filledFields = this.material.numbers.filter(e => e !== '').length;
// append new field
if (filledFields === fieldNo) {
this.material.numbers.push('');
}
// remove if two end fields are empty
if (fieldNo > 1 && this.material.numbers[fieldNo - 1] === '' && this.material.numbers[fieldNo - 2] === '') {
this.material.numbers.pop();
}
}
selectCondition(id) {
this.condition = this.conditionTemplates.find(e => e._id === id);
console.log(this.condition);
console.log(this.sample);
if ('condition_template' in this.sample.condition) {
this.sample.condition.condition_template = id;
}
}
selectMaterialTemplate(id) {
this.materialTemplate = this.materialTemplates.find(e => e._id === id);
if ('material_template' in this.material.properties) {
this.material.properties.material_template = id;
}
}
getMeasurementTemplate(id): TemplateModel {
return this.measurementTemplates && id ? this.measurementTemplates.find(e => e._id === id) : new TemplateModel();
}
addMeasurement() {
this.sample.measurements.push(new MeasurementModel(this.measurementTemplates[0]._id));
this.charts.push(_.cloneDeep(this.chartInit));
}
removeMeasurement(index) {
if (this.sample.measurements[index]._id !== null) {
this.api.delete('/measurement/' + this.sample.measurements[index]._id);
}
this.sample.measurements.splice(index, 1);
this.charts.splice(index, 1);
}
clearChart(index) {
this.charts[index][0].data = [];
}
fileToArray(files, mIndex, parameter) {
const fileReader = new FileReader();
fileReader.onload = () => {
this.sample.measurements[mIndex].values[parameter] = fileReader.result.toString().split('\r\n').map(e => e.split(','));
this.generateChart(this.sample.measurements[mIndex].values[parameter], mIndex);
};
fileReader.readAsText(files[0]);
}
generateChart(spectrum, index) {
this.charts[index][0].data = spectrum.map(e => ({x: parseFloat(e[0]), y: parseFloat(e[1])}));
}
toggleCondition() {
if (this.condition) {
this.condition = null;
}
else {
this.sample.condition = {condition_template: null};
this.selectCondition(this.conditionTemplates[0]._id);
}
}
adjustCustomFields(value, index) {
this.customFields[index][0] = value;
const fieldNo = this.customFields.length;
let filledFields = 0;
this.customFields.forEach(field => {
if (field[0] !== '') {
filledFields ++;
}
});
// append new field
if (filledFields === fieldNo) {
this.customFields.push(['', '']);
}
// remove if two end fields are empty
if (fieldNo > 1 && this.customFields[fieldNo - 1][0] === '' && this.customFields[fieldNo - 2][0] === '') {
this.customFields.pop();
}
}
checkSampleReference(value, index) {
if (value) {
this.sampleReferences[index][0] = value;
}
this.currentSRIndex = index;
const fieldNo = this.sampleReferences.length;
let filledFields = 0;
this.sampleReferences.forEach(field => {
if (field[0] !== '') {
filledFields ++;
}
});
// append new field
if (filledFields === fieldNo) {
this.sampleReferences.push(['', '', '']);
this.sampleReferenceAutocomplete.push([]);
}
// remove if two end fields are empty
if (fieldNo > 1 && this.sampleReferences[fieldNo - 1][0] === '' && this.sampleReferences[fieldNo - 2][0] === '') {
this.sampleReferences.pop();
this.sampleReferenceAutocomplete.pop();
}
this.sampleReferenceIdFind(value);
}
sampleReferenceList(value) {
return new Observable(observer => {
this.api.get<{_id: string, number: string}[]>('/samples?status=all&page-size=25&sort=number-asc&fields[]=number&fields[]=_id&filters[]=%7B%22mode%22%3A%22stringin%22%2C%22field%22%3A%22number%22%2C%22values%22%3A%5B%22' + value + '%22%5D%7D', data => {
console.log(data);
this.sampleReferenceAutocomplete[this.currentSRIndex] = data.map(e => e.number);
this.sampleReferenceFinds = data;
observer.next(data.map(e => e.number));
observer.complete();
this.sampleReferenceIdFind(value);
});
});
}
sampleReferenceIdFind(value) {
const idFind = this.sampleReferenceFinds.find(e => e.number === value);
if (idFind) {
this.sampleReferences[this.currentSRIndex][2] = idFind._id;
}
else {
this.sampleReferences[this.currentSRIndex][2] = '';
}
}
sampleReferenceListBind() {
return this.sampleReferenceList.bind(this);
}
uniqueCfValues(index) { // returns all names until index for unique check
return this.customFields.slice(0, index).map(e => e[0]);
}
}
// 1. ngAfterViewInit wird ja jedes mal nach einem ngOnChanges aufgerufen, also zB wenn sich dein ngFor aufbaut. Du könntest also in der Methode prüfen, ob die Daten schon da sind und dann dementsprechend handeln. Das wäre die Eleganteste Variante
// 2. Der state "dirty" soll eigentlich anzeigen, wenn ein Form-Field vom User geändert wurde; damit missbrauchst du es hier etwas
// 3. Die Dirty-Variante: Pack in deine ngFor ein {{ onFirstLoad(data) }} rein, das einfach ausgeführt wird. müsstest dann natürlich abfangen, dass das nicht nach jedem view-cycle neu getriggert wird. Schön ist das nicht, aber besser als mit Timeouts^^

View File

@ -1,41 +1,130 @@
<div class="header-addnew">
<h2>Samples</h2>
<a routerLink="/samples/new">
<button class="rb-btn rb-primary"><span class="rb-ic rb-ic-add"></span>&nbsp; New sample</button>
</a>
</div>
<rb-accordion>
<rb-accordion-title><span class="rb-ic rb-ic-filter"></span>&nbsp; Filter</rb-accordion-title>
<rb-accordion-title [open]="false"><span class="rb-ic rb-ic-filter"></span>&nbsp; Filter</rb-accordion-title>
<rb-accordion-body>
Not implemented (yet)
<form class="filters">
<div class="status-selection">
<label class="label">Status</label>
<rb-form-checkbox name="status-validated" [(ngModel)]="filters.status.validated" [disabled]="!filters.status.new" (ngModelChange)="loadSamples({firstPage: true})">
validated
</rb-form-checkbox>
<rb-form-checkbox name="status-new" [(ngModel)]="filters.status.new" [disabled]="!filters.status.validated" (ngModelChange)="loadSamples({firstPage: true})">
new
</rb-form-checkbox>
</div>
<rb-form-select name="pageSizeSelection" label="page size" [(ngModel)]="filters.pageSize" class="selection" (ngModelChange)="loadSamples({firstPage: true})" #pageSizeSelection>
<option value="3">3</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
<option value="500">500</option>
</rb-form-select>
<rb-form-multi-select name="fieldSelect" idField="id" [items]="keys" [(ngModel)]="isActiveKey" label="Fields" class="selection" (ngModelChange)="loadSamples({}, $event)">
<span *rbFormMultiSelectOption="let item" class="load-first-page">{{item.label}}</span>
</rb-form-multi-select>
<div class="fieldfilters">
<div *ngFor="let filter of filters.filters">
<ng-container *ngIf="isActiveKey[filter.field]">
<rb-form-checkbox [name]="'filteractive-' + filter.field" [(ngModel)]="filter.active" (ngModelChange)="loadSamples({firstPage: true})"></rb-form-checkbox>
<rb-form-select [name]="'filtermode-' + filter.field" class="filtermode" [(ngModel)]="filter.mode" (ngModelChange)="updateFilterFields(filter.field)">
<option value="eq" title="field is equal to value">=</option>
<option value="ne" title="field is not equal to value">&ne;</option>
<option value="lt" title="field is lower than value">&lt;</option>
<option value="lte" title="field is lower than or equal to value">&le;</option>
<option value="gt" title="field is greater than value">&gt;</option>
<option value="gte" title="field is greater than or equal to value">&ge;</option>
<option value="stringin" title="field contains value">&supe;</option>
<option value="in" title="field is one of the values">&isin;</option>
<option value="nin" title="field is not one of the values">&notin;</option>
</rb-form-select>
<div class="filter-inputs">
<ng-container *ngFor="let ignore of [].constructor(filter.values.length); index as i">
<rb-form-date-input *ngIf="filter.field === 'added'; else noDate" [name]="'filter-' + filter.field + i" [label]="filter.label" [(ngModel)]="filter.values[i]" (ngModelChange)="updateFilterFields(filter.field)"></rb-form-date-input>
<ng-template #noDate>
<rb-form-input *ngIf="!filter.autocomplete.length" [name]="'filter-' + filter.field + i" [label]="filter.label" [(ngModel)]="filter.values[i]" (ngModelChange)="updateFilterFields(filter.field)"></rb-form-input>
<rb-form-input *ngIf="filter.autocomplete.length" [name]="'filter-' + filter.field + i" [label]="filter.label" [rbFormInputAutocomplete]="autocomplete.bind(this, filter.autocomplete)" [rbDebounceTime]="0" (keydown)="preventDefault($event, 'Enter')" [(ngModel)]="filter.values[i]" (ngModelChange)="updateFilterFields(filter.field)" ngModel></rb-form-input>
</ng-template>
</ng-container>
</div>
</ng-container>
</div>
</div>
</form>
</rb-accordion-body>
</rb-accordion>
<ng-container *ngTemplateOutlet="paging"></ng-container>
<div class="download">
<button class="rb-btn rb-secondary" type="button" [rbModal]="linkModal" ><span class="rb-ic rb-ic-download"></span> JSON download link
</button>
<ng-template #linkModal let-close="close">
URL for JSON download:
<textarea class="linkmodal" #linkarea [value]="sampleUrl({export: true, host: true})" (keydown)="preventDefault($event)"></textarea>
<rb-form-checkbox name="download-csv" [(ngModel)]="downloadCsv">
add spectra
</rb-form-checkbox>
<button class="rb-btn rb-secondary" type="button" (click)="clipboard()"><span class="rb-ic rb-ic-clipboard"></span> Copy to clipboard</button>
</ng-template>
<a [href]="csvUrl" download="samples.csv">
<button class="rb-btn rb-secondary" type="button" (mousedown)="csvUrl = sampleUrl({csv: true, export: true})"><span class="rb-ic rb-ic-download"></span> Download result as CSV</button>
</a>
</div>
<rb-table>
<tr>
<th>Number</th>
<th>Material number</th>
<th>Material name</th>
<th>Supplier</th>
<th>Material</th>
<th>GF</th>
<th>CF</th>
<th>M</th>
<th>type</th>
<th>Color</th>
<th>Batch</th>
<th *ngFor="let key of activeKeys">
<div class="sort-header">
<span>{{key.label}}</span>
<span class="rb-ic rb-ic-up sort-arr-up" (click)="setSort(key.id + '-' + 'desc')"><span *ngIf="filters.sort === key.id + '-' + 'desc'"></span></span>
<span class="rb-ic rb-ic-down sort-arr-down" (click)="setSort(key.id + '-' + 'asc')"><span *ngIf="filters.sort === key.id + '-' + 'asc'"></span></span>
</div>
</th>
<th></th>
</tr>
<tr *ngFor="let sample of samples">
<td>{{sample.number}}</td>
<td>{{sample.material_number}}</td>
<td>{{materials[sample.material_id].name}}</td>
<td>{{materials[sample.material_id].supplier}}</td>
<td>{{materials[sample.material_id].group}}</td>
<td>{{materials[sample.material_id].glass_fiber}}</td>
<td>{{materials[sample.material_id].carbon_fiber}}</td>
<td>{{materials[sample.material_id].mineral}}</td>
<td>{{sample.type}}</td>
<td>{{sample.color}}</td>
<td>{{sample.batch}}</td>
<td *ngIf="isActiveKey['number']">{{sample.number}}</td>
<td *ngIf="isActiveKey['material.numbers']">{{materials[sample.material_id].numbers}}</td>
<td *ngIf="isActiveKey['material.name']">{{materials[sample.material_id].name}}</td>
<td *ngIf="isActiveKey['material.supplier']">{{materials[sample.material_id].supplier}}</td>
<td *ngIf="isActiveKey['material.group']">{{materials[sample.material_id].group}}</td>
<td *ngFor="let key of activeTemplateKeys.material">{{materials[sample.material_id].properties[key[2]] | exists}}</td>
<td *ngIf="isActiveKey['type']">{{sample.type}}</td>
<td *ngIf="isActiveKey['color']">{{sample.color}}</td>
<td *ngIf="isActiveKey['batch']">{{sample.batch}}</td>
<td *ngIf="isActiveKey['notes']">{{sample.notes | object}}</td>
<td *ngFor="let key of activeTemplateKeys.measurements">{{sample[key[1]] | exists: key[2]}}</td>
<td *ngIf="isActiveKey['added']">{{sample.added | date:'dd/MM/yy'}}</td>
<td><a [routerLink]="'/samples/edit/' + sample._id"><span class="rb-ic rb-ic-edit"></span></a></td>
</tr>
</rb-table>
<ng-container *ngTemplateOutlet="paging"></ng-container>
<ng-template #paging>
<div class="paging">
<button class="rb-btn rb-link" type="button" (click)="loadPage(-1)" [disabled]="page === 1">
<span class="rb-ic rb-ic-back-left"></span>
</button>
<rb-form-input label="page" (change)="loadPage({toPage: $event.target.value - page})" [ngModel]="page"></rb-form-input>
<span>
of {{pages}} ({{totalSamples}} samples)
</span>
<button class="rb-btn rb-link" type="button" (click)="loadPage(1)" [disabled]="page >= pages">
<span class="rb-ic rb-ic-forward-right"></span>
</button>
</div>
</ng-template>

View File

@ -1,3 +1,5 @@
@import "~@inst-iot/bosch-angular-ui-components/styles/variables/colors";
.header-addnew {
margin-bottom: 40px;
@ -11,3 +13,170 @@
}
}
rb-table {
width: 100%;
}
.rb-ic.rb-ic-edit {
font-size: 1.1rem;
color: $color-gray-silver-sand;
cursor: pointer;
&:hover {
color: #000;
}
}
.status-selection {
overflow: hidden;
margin-bottom: 10px;
float: left;
margin-right: 15px;
label {
display: block;
font-weight: 700;
font-size: 10px;
}
rb-form-checkbox {
float: left;
margin-right: 10px;
margin-top: -10px;
}
}
.selection {
max-width: 230px;
float: left;
}
.paging {
rb-form-input {
max-width: 50px;
}
> * {
float: left;
}
> button {
margin-top: 18px;
}
> span {
margin-top: 20px;
margin-left: 5px;
}
}
.sort-header {
display: inline-grid;
grid-template-columns: 1fr auto;
grid-column-gap: 5px;
width: 100%;
:first-child {
grid-row: span 2;
}
:nth-child(2) {
margin-bottom: -3px;
cursor: pointer;
}
:nth-child(3) {
margin-top: -3px;
cursor: pointer;
}
}
.sort-active-asc {
color: $color-bosch-dark-blue;
background: linear-gradient(to bottom, #FFF 17%, $color-bosch-light-blue-w50 17%);;
border-radius: 0 0 8px 8px;
}
.sort-active-desc {
color: $color-bosch-dark-blue;
background: linear-gradient(to top, #FFF 17%, $color-bosch-light-blue-w50 17%);;
border-radius: 8px 8px 0 0;
}
.filters:after {
content:"";
clear:both;
display:block;
}
.download {
margin-top: 5px;
float: right;
& > rb-form-checkbox {
display: inline-block;
}
button {
margin-right: 10px;
}
}
.sort-arr-up {
position: relative;
& > span {
width: 0;
height: 0;
border-left: 6.3px solid transparent;
border-right: 6.3px solid transparent;
border-bottom: 6.3px solid #000;
position: absolute;
top: 5px;
display: block;
left: 2px;
}
}
.sort-arr-down {
position: relative;
& > span {
width: 0;
height: 0;
border-left: 6.3px solid transparent;
border-right: 6.3px solid transparent;
border-top: 6.3px solid #000;
position: absolute;
top: 5px;
display: block;
left: 2px;
}
}
.fieldfilters {
clear: both;
& > div {
display: grid;
grid-template-columns: auto auto 1fr;
float: left;
margin-right: 30px;
}
}
.filtermode {
max-width: 80px;
}
textarea.linkmodal {
display: block;
min-width: 600px;
min-height: 200px;
border: none;
}
.filter-inputs > * {
display: inline-block;
max-width: 250px;
}

View File

@ -1,25 +1,27 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SamplesComponent } from './samples.component';
describe('SamplesComponent', () => {
let component: SamplesComponent;
let fixture: ComponentFixture<SamplesComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ SamplesComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SamplesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
// import { async, ComponentFixture, TestBed } from '@angular/core/testing';
//
// import { SamplesComponent } from './samples.component';
//
// // TODO
//
// describe('SamplesComponent', () => {
// let component: SamplesComponent;
// let fixture: ComponentFixture<SamplesComponent>;
//
// beforeEach(async(() => {
// TestBed.configureTestingModule({
// declarations: [ SamplesComponent ]
// })
// .compileComponents();
// }));
//
// beforeEach(() => {
// fixture = TestBed.createComponent(SamplesComponent);
// component = fixture.componentInstance;
// fixture.detectChanges();
// });
//
// it('should create', () => {
// expect(component).toBeTruthy();
// });
// });

View File

@ -1,35 +1,273 @@
import { Component, OnInit } from '@angular/core';
import {ApiService} from '../api.service';
import {Component, ElementRef, isDevMode, OnInit, ViewChild} from '@angular/core';
import {ApiService} from '../services/api.service';
import {AutocompleteService} from '../services/autocomplete.service';
import _ from 'lodash';
interface LoadSamplesOptions {
toPage?: number;
event?: Event;
firstPage?: boolean;
}
interface KeyInterface {
id: string;
label: string;
active: boolean;
}
@Component({
selector: 'app-samples',
templateUrl: './samples.component.html',
styleUrls: ['./samples.component.scss']
})
export class SamplesComponent implements OnInit { // TODO: implement paging
// TODO: manage branches, introduce versioning, only upload ui from master
// TODO: check if custom-header.conf works, add headers from helmet https://docs.cloudfoundry.org/buildpacks/staticfile/index.html
export class SamplesComponent implements OnInit {
@ViewChild('pageSizeSelection') pageSizeSelection: ElementRef<HTMLElement>;
@ViewChild('linkarea') linkarea: ElementRef<HTMLTextAreaElement>;
downloadCsv = false;
materials = {};
samples = [];
totalSamples = 0; // total number of samples
csvUrl = ''; // store url separate so it only has to be generated when clicking the download button
filters = {
status: {new: true, validated: true},
pageSize: 25,
toPage: 0,
sort: 'added-asc',
filters: [
{field: 'number', label: 'Number', active: false, autocomplete: [], mode: 'eq', values: ['']},
{field: 'material.number', label: 'Material number', active: false, autocomplete: [], mode: 'eq', values: ['']},
{field: 'material.name', label: 'Material name', active: false, autocomplete: [], mode: 'eq', values: ['']},
{field: 'material.supplier', label: 'Supplier', active: false, autocomplete: [], mode: 'eq', values: ['']},
{field: 'material.group', label: 'Material', active: false, autocomplete: [], mode: 'eq', values: ['']},
{field: 'material.glass_fiber', label: 'GF', active: false, autocomplete: [], mode: 'eq', values: ['']},
{field: 'material.carbon_fiber', label: 'CF', active: false, autocomplete: [], mode: 'eq', values: ['']},
{field: 'material.mineral', label: 'M', active: false, autocomplete: [], mode: 'eq', values: ['']},
{field: 'type', label: 'Type', active: false, autocomplete: [], mode: 'eq', values: ['']},
{field: 'color', label: 'Color', active: false, autocomplete: [], mode: 'eq', values: ['']},
{field: 'batch', label: 'Batch', active: false, autocomplete: [], mode: 'eq', values: ['']},
{field: 'notes', label: 'Notes', active: false, autocomplete: [], mode: 'eq', values: ['']},
{field: 'added', label: 'Added', active: false, autocomplete: [], mode: 'eq', values: [new Date()]}
]
};
page = 1;
pages = 1;
loadSamplesQueue = []; // arguments of queued up loadSamples() calls
apiKey = '';
keys: KeyInterface[] = [
{id: 'number', label: 'Number', active: true},
{id: 'material.numbers', label: 'Material numbers', active: true},
{id: 'material.name', label: 'Material name', active: true},
{id: 'material.supplier', label: 'Supplier', active: true},
{id: 'material.group', label: 'Material', active: false},
{id: 'type', label: 'Type', active: true},
{id: 'color', label: 'Color', active: true},
{id: 'batch', label: 'Batch', active: true},
{id: 'notes', label: 'Notes', active: false},
{id: 'added', label: 'Added', active: true}
];
isActiveKey: {[key: string]: boolean} = {};
activeKeys: KeyInterface[] = [];
activeTemplateKeys = {material: [], measurements: []};
constructor(
private api: ApiService
) { }
private api: ApiService,
public autocomplete: AutocompleteService
) {
}
ngOnInit(): void {
this.api.get('/materials').subscribe((mData: any) => {
this.calcFieldSelectKeys();
this.api.get('/materials?status=all', (mData: any) => {
this.materials = {};
mData.forEach(material => {
this.materials[material._id] = material;
});
console.log(this.materials);
this.api.get('/samples').subscribe(sData => {
console.log(sData);
this.samples = sData as any;
this.samples.forEach(sample => {
sample.material_number = this.materials[sample.material_id].numbers.find(e => sample.color === e.color).number;
this.filters.filters.find(e => e.field === 'material.name').autocomplete = mData.map(e => e.name);
this.filters.filters.find(e => e.field === 'color').autocomplete = [...new Set(mData.reduce((s, e) => {s.push(...e.numbers.map(el => el.color)); return s; }, []))];
this.loadSamples();
});
this.api.get('/user/key', (data: {key: string}) => {
this.apiKey = data.key;
});
this.api.get<string[]>('/material/suppliers', (data: any) => {
this.filters.filters.find(e => e.field === 'material.supplier').autocomplete = data;
});
this.api.get<string[]>('/material/groups', (data: any) => {
this.filters.filters.find(e => e.field === 'material.group').autocomplete = data;
});
this.loadTemplateKeys('materials', 'type');
this.loadTemplateKeys('measurements', 'added');
}
loadTemplateKeys(collection, insertBefore) {
this.api.get('/template/' + collection, (data: {name: string, parameters: {name: string, range: object}[]}[]) => {
const templateKeys = [];
data.forEach(item => {
item.parameters.forEach(parameter => {
templateKeys.push({id: `${collection === 'materials' ? 'material' : collection}.${collection === 'materials' ? 'properties' : item.name}.${encodeURIComponent(parameter.name)}`, label: `${this.ucFirst(item.name)} ${this.ucFirst(parameter.name)}`, active: false});
this.filters.filters.push({field: `${collection === 'materials' ? 'material' : collection}.${collection === 'materials' ? 'properties' : item.name}.${parameter.name}`, label: `${this.ucFirst(item.name)} ${this.ucFirst(parameter.name)}`, active: false, autocomplete: [], mode: 'eq', values: ['']});
});
});
this.keys.splice(this.keys.findIndex(e => e.id === insertBefore), 0, ...templateKeys);
this.keys = [...this.keys]; // complete overwrite array to invoke update in rb-multiselect
this.updateActiveKeys();
this.calcFieldSelectKeys();
});
}
loadSamples(options: LoadSamplesOptions = {}, event = null) { // set toPage to null to reload first page, queues calls
if (event) { // adjust active keys
this.keys.forEach(key => {
if (event.hasOwnProperty(key.id)) {
key.active = event[key.id];
}
});
this.updateActiveKeys();
}
this.loadSamplesQueue.push(options);
if (this.loadSamplesQueue.length <= 1) { // nothing queued up
this.sampleLoader(this.loadSamplesQueue[0]);
}
}
private sampleLoader(options: LoadSamplesOptions) { // actual loading of the sample, do not call directly
this.api.get(this.sampleUrl({paging: true, pagingOptions: options}), (sData, ignore, headers) => {
if (!options.toPage && headers['x-total-items']) {
this.totalSamples = headers['x-total-items'];
}
this.pages = Math.ceil(this.totalSamples / this.filters.pageSize);
this.samples = sData as any;
this.loadSamplesQueue.shift();
if (this.loadSamplesQueue.length > 0) { // execute next queue item
this.sampleLoader(this.loadSamplesQueue[0]);
}
});
}
sampleUrl(options: {paging?: boolean, pagingOptions?: {firstPage?: boolean, toPage?: number, event?: Event}, csv?: boolean, export?: boolean, host?: boolean}) { // return url to fetch samples
const additionalTableKeys = ['material_id', '_id']; // keys which should always be added if export = false
const query: string[] = [];
query.push('status=' + (this.filters.status.new && this.filters.status.validated ? 'all' : (this.filters.status.new ? 'new' : 'validated')));
if (options.paging) {
if (this.samples[0]) { // do not include from-id when page size was changed
if (!options.pagingOptions.firstPage) {
query.push('from-id=' + this.samples[0]._id);
}
else {
this.page = 1;
}
}
if (options.pagingOptions.toPage) {
query.push('to-page=' + options.pagingOptions.toPage);
}
query.push('page-size=' + this.filters.pageSize);
}
query.push('sort=' + this.filters.sort);
if (options.csv) {
query.push('csv=true');
}
if (options.export) {
query.push('key=' + this.apiKey);
}
this.keys.forEach(key => {
if (key.active && (options.export || (!options.export && key.id.indexOf('material') < 0))) { // do not load material properties for table
query.push('fields[]=' + key.id);
}
});
console.log(this.filters.filters);
query.push(..._.cloneDeep(this.filters.filters)
.map(e => {
e.values = e.values.filter(el => el !== ''); // do not include empty values
if (e.field === 'added') { // correct timezone
e.values = e.values.map(el => new Date(new Date(el).getTime() - new Date(el).getTimezoneOffset() * 60000).toISOString());
}
return e;
})
.filter(e => e.active && e.values.length > 0)
.map(e => 'filters[]=' + encodeURIComponent(JSON.stringify(_.pick(e, ['mode', 'field', 'values']))))
);
console.log(this.filters);
if (!options.export) {
additionalTableKeys.forEach(key => {
if (query.indexOf('fields[]=' + key) < 0) { // add key if not already added
query.push('fields[]=' + key);
}
});
}
else if (this.downloadCsv) {
query.push('fields[]=measurements.spectrum.dpt');
}
console.log('/samples?' + query.join('&'));
return (options.host && isDevMode() ? window.location.host : '') + (options.export ? this.api.hostName : '') + '/samples?' + query.join('&');
}
loadPage(delta) {
if (!/[0-9]+/.test(delta) || (this.page <= 1 && delta < 0)) { // invalid delta
return;
}
this.page += delta;
this.loadSamples({toPage: delta});
}
updateFilterFields(field) {
const filter = this.filters.filters.find(e => e.field === field);
if (filter.mode === 'in' || filter.mode === 'nin') {
if (filter.values[filter.values.length - 1] === '' && filter.values[filter.values.length - 2] === '') {
filter.values.pop();
}
else if (filter.values[filter.values.length - 1] !== '') {
filter.values.push((filter.field === 'added' ? new Date() : '') as string & Date);
}
}
else {
filter.values = [filter.values[0] as string & Date];
}
if (filter.active) {
this.loadSamples({firstPage: true});
}
}
setSort(string) {
this.filters.sort = string;
this.loadSamples({firstPage: true});
}
updateActiveKeys() { // array with all activeKeys
this.activeKeys = this.keys.filter(e => e.active);
this.activeTemplateKeys.material = this.keys.filter(e => e.id.indexOf('material.properties.') >= 0 && e.active).map(e => e.id.split('.').map(el => decodeURIComponent(el)));
this.activeTemplateKeys.measurements = this.keys.filter(e => e.id.indexOf('measurements.') >= 0 && e.active).map(e => e.id.split('.').map(el => decodeURIComponent(el)));
console.log(this.activeTemplateKeys);
console.log(this.keys); // TODO: glass fiber filter not working
}
calcFieldSelectKeys() {
this.keys.forEach(key => {
this.isActiveKey[key.id] = key.active;
});
}
preventDefault(event, key = 'all') {
if (key === 'all' || event.key === key) {
event.preventDefault();
}
}
clipboard() {
this.linkarea.nativeElement.select();
this.linkarea.nativeElement.setSelectionRange(0, 99999);
document.execCommand('copy');
}
ucFirst(string) {
return string[0].toUpperCase() + string.slice(1);
}
}

View File

@ -0,0 +1,138 @@
import {async, TestBed} from '@angular/core/testing';
import { ApiService } from './api.service';
import {HttpClient} from '@angular/common/http';
import {LocalStorageService} from 'angular-2-local-storage';
import {Observable} from 'rxjs';
import {ModalService} from '@inst-iot/bosch-angular-ui-components';
let apiService: ApiService;
let httpClientSpy: jasmine.SpyObj<HttpClient>;
let localStorageServiceSpy: jasmine.SpyObj<LocalStorageService>;
let modalServiceSpy: jasmine.SpyObj<ModalService>;
// TODO: test options
describe('ApiService', () => {
beforeEach(() => {
const httpSpy = jasmine.createSpyObj('HttpClient', ['get', 'post', 'put', 'delete']);
const localStorageSpy = jasmine.createSpyObj('LocalStorageService', ['get']);
const modalSpy = jasmine.createSpyObj('ModalService', ['openComponent']);
TestBed.configureTestingModule({
providers: [
ApiService,
{provide: HttpClient, useValue: httpSpy},
{provide: LocalStorageService, useValue: localStorageSpy},
{provide: ModalService, useValue: modalSpy}
]
});
apiService = TestBed.inject(ApiService);
httpClientSpy = TestBed.inject(HttpClient) as jasmine.SpyObj<HttpClient>;
localStorageServiceSpy = TestBed.inject(LocalStorageService) as jasmine.SpyObj<LocalStorageService>;
modalServiceSpy = TestBed.inject(ModalService) as jasmine.SpyObj<ModalService>;
});
it('should be created', () => {
expect(apiService).toBeTruthy();
});
it('shows an error message when the request fails', () => {
const getReturn = new Observable(observer => {
observer.error('error');
});
httpClientSpy.get.and.returnValue(getReturn);
localStorageServiceSpy.get.and.returnValue(undefined);
modalServiceSpy.openComponent.and.returnValue({instance: {message: ''}} as any);
apiService.get('/testurl');
expect(httpClientSpy.get).toHaveBeenCalledWith('/api/testurl', jasmine.any(Object));
expect(modalServiceSpy.openComponent.calls.count()).toBe(1);
});
it('returns the error message if the callback function had an error parameter', () => {
const getReturn = new Observable(observer => {
observer.error('error');
});
httpClientSpy.get.and.returnValue(getReturn);
localStorageServiceSpy.get.and.returnValue(undefined);
modalServiceSpy.openComponent.and.returnValue({instance: {message: ''}} as any);
apiService.get('/testurl', (data, error) => {
expect(modalServiceSpy.openComponent.calls.count()).toBe(0);
expect(error).toBe('error');
});
});
it('should do get requests without auth if not available', async(() => {
const getReturn = new Observable(observer => {
observer.next('data');
});
httpClientSpy.get.and.returnValue(getReturn);
localStorageServiceSpy.get.and.returnValue(undefined);
apiService.get('/testurl', res => {
expect(res).toBe('data');
expect(httpClientSpy.get).toHaveBeenCalledWith('/api/testurl', {});
expect(localStorageServiceSpy.get).toHaveBeenCalledWith('basicAuth');
});
}));
it('should do get requests with basic auth if available', async(() => {
const getReturn = new Observable(observer => {
observer.next('data');
});
httpClientSpy.get.and.returnValue(getReturn);
localStorageServiceSpy.get.and.returnValue('basicAuth');
apiService.get('/testurl', res => {
expect(res).toBe('data');
expect(httpClientSpy.get).toHaveBeenCalledWith('/api/testurl', jasmine.any(Object)); // could not test http headers better
expect(localStorageServiceSpy.get).toHaveBeenCalledWith('basicAuth');
});
}));
it('should do post requests', async(() => {
const resReturn = new Observable(observer => {
observer.next('data');
});
httpClientSpy.post.and.returnValue(resReturn);
localStorageServiceSpy.get.and.returnValue('basicAuth');
apiService.post('/testurl', 'reqData', res => {
expect(res).toBe('data');
expect(httpClientSpy.post).toHaveBeenCalledWith('/api/testurl', 'reqData', jasmine.any(Object));
expect(localStorageServiceSpy.get).toHaveBeenCalledWith('basicAuth');
});
}));
it('should do put requests', async(() => {
const resReturn = new Observable(observer => {
observer.next('data');
});
httpClientSpy.put.and.returnValue(resReturn);
localStorageServiceSpy.get.and.returnValue('basicAuth');
apiService.put('/testurl', 'reqData', res => {
expect(res).toBe('data');
expect(httpClientSpy.put).toHaveBeenCalledWith('/api/testurl', 'reqData', jasmine.any(Object));
expect(localStorageServiceSpy.get).toHaveBeenCalledWith('basicAuth');
});
}));
it('should do delete requests', async(() => {
const resReturn = new Observable(observer => {
observer.next('data');
});
httpClientSpy.delete.and.returnValue(resReturn);
localStorageServiceSpy.get.and.returnValue('basicAuth');
apiService.delete('/testurl', res => {
expect(res).toBe('data');
expect(httpClientSpy.delete).toHaveBeenCalledWith('/api/testurl', jasmine.any(Object));
expect(localStorageServiceSpy.get).toHaveBeenCalledWith('basicAuth');
});
}));
// TODO: test return headers
});

View File

@ -0,0 +1,73 @@
import { Injectable, isDevMode } from '@angular/core';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {LocalStorageService} from 'angular-2-local-storage';
import {Observable} from 'rxjs';
import {ErrorComponent} from '../error/error.component';
import {ModalService} from '@inst-iot/bosch-angular-ui-components';
@Injectable({
providedIn: 'root'
})
export class ApiService {
private host = isDevMode() ? '/api' : 'https://definma-api.apps.de1.bosch-iot-cloud.com';
constructor(
private http: HttpClient,
private storage: LocalStorageService,
private modalService: ModalService,
private window: Window
) { }
get hostName() {
return this.host;
}
get<T>(url, f: (data?: T, err?, headers?) => void = () => {}) {
this.requestErrorHandler<T>(this.http.get(this.host + url, this.options()), f);
}
post<T>(url, data = null, f: (data?: T, err?, headers?) => void = () => {}) {
this.requestErrorHandler<T>(this.http.post(this.host + url, data, this.options()), f);
}
put<T>(url, data = null, f: (data?: T, err?, headers?) => void = () => {}) {
this.requestErrorHandler<T>(this.http.put(this.host + url, data, this.options()), f);
}
delete<T>(url, f: (data?: T, err?, headers?) => void = () => {}) {
this.requestErrorHandler<T>(this.http.delete(this.host + url, this.options()), f);
}
private requestErrorHandler<T>(observable: Observable<any>, f: (data?: T, err?, headers?) => void) {
observable.subscribe(data => {
f(data.body, undefined, data.headers.keys().reduce((s, e) => {s[e.toLowerCase()] = data.headers.get(e); return s; }, {}));
}, err => {
if (f.length === 2) {
f(undefined, err);
}
else {
const modalRef = this.modalService.openComponent(ErrorComponent);
modalRef.instance.message = 'Network request failed!';
modalRef.result.then(() => {
this.window.location.reload();
});
}
});
}
private options(): {headers: HttpHeaders, observe: 'body'} {
return {headers: this.authOptions(), observe: 'response' as 'body'};
}
private authOptions(): HttpHeaders {
const auth = this.storage.get('basicAuth');
if (auth) {
return new HttpHeaders({Authorization: 'Basic ' + auth});
}
else {
return new HttpHeaders();
}
}
}

View File

@ -0,0 +1,35 @@
import { TestBed } from '@angular/core/testing';
import { AutocompleteService } from './autocomplete.service';
let autocompleteService: AutocompleteService;
describe('AutocompleteService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [AutocompleteService]
});
autocompleteService = TestBed.inject(AutocompleteService);
});
it('should be created', () => {
expect(autocompleteService).toBeTruthy();
});
it('should should return a bind function', () => {
expect(autocompleteService.bind('a', ['b'])).toBeTruthy();
});
it('should return search results', () => {
autocompleteService.search(['aa', 'ab', 'bb'], 'a').subscribe(res => {
expect(res).toEqual(['aa', 'ab']);
});
});
it('should return an empty array if no result was found', () => {
autocompleteService.search(['aa', 'ab', 'bb'], 'c').subscribe(res => {
expect(res).toEqual([]);
});
});
});

View File

@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
import {QuickScore} from 'quick-score';
import {of} from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class AutocompleteService {
constructor() { }
bind(ref, list: string[]) {
return this.search.bind(ref, list);
}
search(arr: string[], str: string) {
const qs = new QuickScore(arr);
return of(str === '' ? [] : qs.search(str).map(e => e.item));
}
}

View File

@ -3,7 +3,6 @@ import { TestBed } from '@angular/core/testing';
import { LoginService } from './login.service';
import {LocalStorageService} from 'angular-2-local-storage';
import {ApiService} from './api.service';
import {Observable} from 'rxjs';
let loginService: LoginService;
let apiServiceSpy: jasmine.SpyObj<ApiService>;
@ -33,7 +32,7 @@ describe('LoginService', () => {
describe('login', () => {
it('should store the basic auth', () => {
localStorageServiceSpy.set.and.returnValue(true);
apiServiceSpy.get.and.returnValue(new Observable());
apiServiceSpy.get.and.callFake(() => {});
loginService.login('username', 'password');
expect(localStorageServiceSpy.set).toHaveBeenCalledWith('basicAuth', 'dXNlcm5hbWU6cGFzc3dvcmQ=');
});
@ -41,7 +40,7 @@ describe('LoginService', () => {
it('should remove the basic auth if login fails', () => {
localStorageServiceSpy.set.and.returnValue(true);
localStorageServiceSpy.remove.and.returnValue(true);
apiServiceSpy.get.and.returnValue(new Observable(o => o.error()));
apiServiceSpy.get.and.callFake((a, b) => {b(undefined, 'error'); });
loginService.login('username', 'password');
expect(localStorageServiceSpy.remove.calls.count()).toBe(1);
expect(localStorageServiceSpy.remove).toHaveBeenCalledWith('basicAuth');
@ -49,33 +48,40 @@ describe('LoginService', () => {
it('should resolve true when login succeeds', async () => {
localStorageServiceSpy.set.and.returnValue(true);
apiServiceSpy.get.and.returnValue(new Observable(o => o.next({status: 'Authorization successful', method: 'basic'})));
apiServiceSpy.get.and.callFake((a, b) => {b({status: 'Authorization successful', method: 'basic'} as any, undefined); });
expect(await loginService.login('username', 'password')).toBeTruthy();
});
it('should resolve false when a wrong result comes in', async () => {
localStorageServiceSpy.set.and.returnValue(true);
apiServiceSpy.get.and.returnValue(new Observable(o => o.next({status: 'xxx', method: 'basic'})));
apiServiceSpy.get.and.callFake((a, b) => {b({status: 'xxx', method: 'basic'} as any, undefined); });
expect(await loginService.login('username', 'password')).toBeFalsy();
});
it('should resolve false on an error', async () => {
localStorageServiceSpy.set.and.returnValue(true);
apiServiceSpy.get.and.returnValue(new Observable(o => o.error()));
apiServiceSpy.get.and.callFake((a, b) => {b(undefined, 'error'); });
expect(await loginService.login('username', 'password')).toBeFalsy();
});
});
describe('canActivate', () => {
it('should return false at first', () => {
expect(loginService.canActivate(null, null)).toBeFalsy();
it('should return false at first', done => {
apiServiceSpy.get.and.callFake((a, b) => {b(undefined, 'error'); });
loginService.canActivate(null, null).subscribe(res => {
expect(res).toBeFalsy();
done();
});
});
it('returns true if login was successful', async () => {
it('returns true if login was successful', async done => {
localStorageServiceSpy.set.and.returnValue(true);
apiServiceSpy.get.and.returnValue(new Observable(o => o.next({status: 'Authorization successful', method: 'basic'})));
apiServiceSpy.get.and.callFake((a, b) => {b({status: 'Authorization successful', method: 'basic'} as any, undefined); });
await loginService.login('username', 'password');
expect(loginService.canActivate(null, null)).toBeTruthy();
loginService.canActivate(null, null).subscribe(res => {
expect(res).toBeTruthy();
done();
});
});
});
});

View File

@ -2,19 +2,20 @@ import { Injectable } from '@angular/core';
import {ApiService} from './api.service';
import {ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot} from '@angular/router';
import {LocalStorageService} from 'angular-2-local-storage';
import {Observable} from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class LoginService implements CanActivate {
private loggedIn = false;
private loggedIn;
constructor(
private api: ApiService,
private storage: LocalStorageService
) {
this.login();
}
login(username = '', password = '') {
@ -22,26 +23,50 @@ export class LoginService implements CanActivate {
if (username !== '') {
this.storage.set('basicAuth', btoa(username + ':' + password));
}
this.api.get('/authorized').subscribe((data: any) => {
this.api.get('/authorized', (data: any, error) => {
if (!error) {
if (data.status === 'Authorization successful') {
this.loggedIn = true;
resolve(true);
}
else {
} else {
this.loggedIn = false;
this.storage.remove('basicAuth');
resolve(false);
}
},
() => {
} else {
this.loggedIn = false;
this.storage.remove('basicAuth');
resolve(false);
}
});
});
}
canActivate(route: ActivatedRouteSnapshot = null, state: RouterStateSnapshot = null) {
logout() {
this.storage.remove('basicAuth');
this.loggedIn = false;
}
canActivate(route: ActivatedRouteSnapshot = null, state: RouterStateSnapshot = null): Observable<boolean> {
return new Observable<boolean>(observer => {
if (this.loggedIn === undefined) {
this.login().then(res => {
observer.next(res as any);
observer.complete();
});
}
else {
observer.next(this.loggedIn);
observer.complete();
}
});
}
get isLoggedIn() {
return this.loggedIn;
}
get username() {
return atob(this.storage.get('basicAuth')).split(':')[0];
}
}

View File

@ -0,0 +1,114 @@
import { TestBed } from '@angular/core/testing';
import { ValidationService } from './validation.service';
let validationService: ValidationService;
describe('ValidationService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ValidationService]
});
validationService = TestBed.inject(ValidationService);
});
it('should be created', () => {
expect(validationService).toBeTruthy();
});
it('should return true on a correct username', () => {
expect(validationService.username('abc')).toEqual({ok: true, error: ''});
});
it('should return an error on an incorrect username', () => {
expect(validationService.username('abc#')).toEqual({ok: false, error: 'username must only contain a-z0-9-_.'});
});
it('should return true on a correct password', () => {
expect(validationService.password('Abc123!#')).toEqual({ok: true, error: ''});
});
it('should return an error on a password too short', () => {
expect(validationService.password('Abc123')).toEqual({ok: false, error: 'password must have at least 8 characters'});
});
it('should return an error on a password without a lowercase letter', () => {
expect(validationService.password('ABC123!#')).toEqual({ok: false, error: 'password must have at least one lowercase character'});
});
it('should return an error on a password without an uppercase letter', () => {
expect(validationService.password('abc123!#')).toEqual({ok: false, error: 'password must have at least one uppercase character'});
});
it('should return an error on a password without a number', () => {
expect(validationService.password('Abcabc!#')).toEqual({ok: false, error: 'password must have at least one number'});
});
it('should return an error on a password without a special character', () => {
expect(validationService.password('Abc12345')).toEqual({ok: false, error: 'password must have at least one of the following characters !"#%&\'()*+,-.\\/:;<=>?@[]^_`{|}~'});
});
it('should return an error on a password with a character not allowed', () => {
expect(validationService.password('Abc123!€')).toEqual({ok: false, error: 'password must only contain a-zA-Z0-9!"#%&\'()*+,-./:;<=>?@[]^_`{|}~'});
});
it('should return true on a correct string', () => {
expect(validationService.string('Abc')).toEqual({ok: true, error: ''});
});
it('should return an error on a string too long', () => {
expect(validationService.string('abcabcabcbabcbabcabcabacbabcabcabcbabcbabcabcabacbabcabcabcbabcbabcabcabacbabcabcabcbabcbabcabcabacbabcabcabcbabcbabcabcabacbacab')).toEqual({ok: false, error: 'must contain max 128 characters'});
});
it('should return true on a string in the list', () => {
expect(validationService.stringOf('Abc', ['Abc', 'Def'])).toEqual({ok: true, error: ''});
});
it('should return an error on a string not in the list', () => {
expect(validationService.stringOf('abc', ['Abc', 'Def'])).toEqual({ok: false, error: 'must be one of Abc, Def'});
});
it('should return true on a string of correct length', () => {
expect(validationService.stringLength('Abc', 5)).toEqual({ok: true, error: ''});
});
it('should return an error on a string longer than specified', () => {
expect(validationService.stringLength('Abc', 2)).toEqual({ok: false, error: 'must contain max 2 characters'});
});
it('should return true on a number in the range', () => {
expect(validationService.minMax(2, -2, 2)).toEqual({ok: true, error: ''});
});
it('should return an error on a number below the range', () => {
expect(validationService.minMax(0, 1, 3)).toEqual({ok: false, error: 'must be between 1 and 3'});
});
it('should return an error on a number above the range', () => {
expect(validationService.minMax(3.1, 1, 3)).toEqual({ok: false, error: 'must be between 1 and 3'});
});
it('should return true on a number above min', () => {
expect(validationService.min(2, -2)).toEqual({ok: true, error: ''});
});
it('should return an error on a number below min', () => {
expect(validationService.min(0, 1)).toEqual({ok: false, error: 'must not be below 1'});
});
it('should return true on a number below max', () => {
expect(validationService.max(2, 2)).toEqual({ok: true, error: ''});
});
it('should return an error on a number above max', () => {
expect(validationService.max(2, 1)).toEqual({ok: false, error: 'must not be above 1'});
});
it('should return true on a string not in the list', () => {
expect(validationService.unique('Abc', ['Def', 'Ghi'])).toEqual({ok: true, error: ''});
});
it('should return an error on a string from the list', () => {
expect(validationService.unique('Abc', ['Abc', 'Def'])).toEqual({ok: false, error: 'values must be unique'});
});
});

View File

@ -0,0 +1,124 @@
import { Injectable } from '@angular/core';
import Joi from '@hapi/joi';
import {AbstractControl} from '@angular/forms';
@Injectable({
providedIn: 'root'
})
export class ValidationService {
private vUsername = Joi.string()
.lowercase()
.pattern(new RegExp('^[a-z0-9-_.]+$'))
.min(1)
.max(128);
private vPassword = Joi.string()
.pattern(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&'()*+,-.\/:;<=>?@[\]^_`{|}~])(?=\S+$)[a-zA-Z0-9!"#%&'()*+,\-.\/:;<=>?@[\]^_`{|}~]{8,}$/)
.max(128);
constructor() { }
generate(method, args) { // generate a Validator function
return (control: AbstractControl): {[key: string]: any} | null => {
let ok;
let error;
if (args) {
({ok, error} = this[method](control.value, ...args));
}
else {
({ok, error} = this[method](control.value));
}
return ok ? null : { failure: error };
};
}
username(data) {
const {ignore, error} = this.vUsername.validate(data);
if (error) {
return {ok: false, error: 'username must only contain a-z0-9-_.'};
}
return {ok: true, error: ''};
}
password(data) {
const {ignore, error} = this.vPassword.validate(data);
if (error) {
if (Joi.string().min(8).validate(data).error) {
return {ok: false, error: 'password must have at least 8 characters'};
}
else if (Joi.string().pattern(/[a-z]+/).validate(data).error) {
return {ok: false, error: 'password must have at least one lowercase character'};
}
else if (Joi.string().pattern(/[A-Z]+/).validate(data).error) {
return {ok: false, error: 'password must have at least one uppercase character'};
}
else if (Joi.string().pattern(/[0-9]+/).validate(data).error) {
return {ok: false, error: 'password must have at least one number'};
}
else if (Joi.string().pattern(/[!"#%&'()*+,-.\/:;<=>?@[\]^_`{|}~]+/).validate(data).error) {
return {ok: false, error: 'password must have at least one of the following characters !"#%&\'()*+,-.\\/:;<=>?@[]^_`{|}~'};
}
else {
return {ok: false, error: 'password must only contain a-zA-Z0-9!"#%&\'()*+,-./:;<=>?@[]^_`{|}~'};
}
}
return {ok: true, error: ''};
}
string(data) {
const {ignore, error} = Joi.string().max(128).allow('').validate(data);
if (error) {
return {ok: false, error: 'must contain max 128 characters'};
}
return {ok: true, error: ''};
}
stringOf(data, list) {
const {ignore, error} = Joi.string().allow('').valid(...list.map(e => e.toString())).validate(data);
if (error) {
return {ok: false, error: 'must be one of ' + list.join(', ')};
}
return {ok: true, error: ''};
}
stringLength(data, length) {
const {ignore, error} = Joi.string().max(length).allow('').validate(data);
if (error) {
return {ok: false, error: 'must contain max ' + length + ' characters'};
}
return {ok: true, error: ''};
}
minMax(data, min, max) {
const {ignore, error} = Joi.number().allow('').min(min).max(max).validate(data);
if (error) {
return {ok: false, error: `must be between ${min} and ${max}`};
}
return {ok: true, error: ''};
}
min(data, min) {
const {ignore, error} = Joi.number().allow('').min(min).validate(data);
if (error) {
return {ok: false, error: `must not be below ${min}`};
}
return {ok: true, error: ''};
}
max(data, max) {
const {ignore, error} = Joi.number().allow('').max(max).validate(data);
if (error) {
return {ok: false, error: `must not be above ${max}`};
}
return {ok: true, error: ''};
}
unique(data, list) {
const {ignore, error} = Joi.string().allow('').invalid(...list.map(e => e.toString())).validate(data);
if (error) {
return {ok: false, error: `values must be unique`};
}
return {ok: true, error: ''};
}
}

View File

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

View File

@ -0,0 +1,28 @@
import {Directive, Input} from '@angular/core';
import {AbstractControl, NG_VALIDATORS} from '@angular/forms';
import {ValidationService} from './services/validation.service';
@Directive({
selector: '[appValidate]',
providers: [{provide: NG_VALIDATORS, useExisting: ValidateDirective, multi: true}]
})
export class ValidateDirective {
@Input('appValidate') method: string;
@Input('appValidateArgs') args: Array<any>;
constructor(
private validation: ValidationService
) { }
validate(control: AbstractControl): {[key: string]: any} | null {
let ok;
let error;
if (this.args) {
({ok, error} = this.validation[this.method](control.value, ...this.args));
}
else {
({ok, error} = this.validation[this.method](control.value));
}
return ok ? null : { failure: error };
}
}

View File

@ -1,35 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { ValidationService } from './validation.service';
let validationService: ValidationService;
describe('ValidationService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ValidationService]
});
validationService = TestBed.inject(ValidationService);
});
it('should be created', () => {
expect(validationService).toBeTruthy();
});
it('should return true on a correct username', () => {
expect(validationService.username('abc')).toEqual({ok: true, error: ''});
});
it('should return an error on an incorrect username', () => {
expect(validationService.username('abc#')).toEqual({ok: false, error: 'username must only contain a-z0-9-_.'});
});
it('should return true on a correct password', () => {
expect(validationService.password('Abc123!#')).toEqual({ok: true, error: ''});
});
it('should return an error on an incorrect password', () => {
expect(validationService.password('Abc123')).toEqual({ok: false, error: 'password must only contain a-zA-Z0-9!"#%&\'()*+,-./:;<=>?@[]^_`{|}~'});
});
});

View File

@ -1,36 +0,0 @@
import { Injectable } from '@angular/core';
import Joi from '@hapi/joi';
@Injectable({
providedIn: 'root'
})
export class ValidationService {
private vUsername = Joi.string()
.lowercase()
.pattern(new RegExp('^[a-z0-9-_.]+$'))
.min(1)
.max(128);
private vPassword = Joi.string()
.pattern(new RegExp('^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$'))
.max(128);
constructor() { }
username(data) {
const {ignore, error} = this.vUsername.validate(data);
if (error) {
return {ok: false, error: 'username must only contain a-z0-9-_.'};
}
return {ok: true, error: ''};
}
password(data) {
const {ignore, error} = this.vPassword.validate(data);
if (error) {
return {ok: false, error: 'password must only contain a-zA-Z0-9!"#%&\'()*+,-./:;<=>?@[]^_`{|}~'};
}
return {ok: true, error: ''};
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 90 KiB

View File

@ -21,6 +21,7 @@
},
"angularCompilerOptions": {
"fullTemplateTypeCheck": true,
"strictInjectionParameters": true
"strictInjectionParameters": true,
"debug": true
}
}

View File

@ -34,10 +34,7 @@
],
"interface-name": false,
"max-classes-per-file": false,
"max-line-length": [
true,
140
],
"max-line-length": false,
"member-access": false,
"member-ordering": [
true,
@ -73,6 +70,7 @@
"as-needed"
],
"object-literal-sort-keys": false,
"one-line": false,
"ordered-imports": false,
"quotemark": [
true,
@ -90,7 +88,15 @@
"template-banana-in-box": true,
"template-no-negated-async": true,
"use-lifecycle-interface": true,
"use-pipe-transform-interface": true
"use-pipe-transform-interface": true,
"variable-name": {
"options": [
"allow-leading-underscore",
"allow-pascal-case",
"allow-snake-case",
"check-format"
]
}
},
"rulesDirectory": [
"codelyzer"