diff --git a/angular.json b/angular.json index 126fb2c..066b70a 100644 --- a/angular.json +++ b/angular.json @@ -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" diff --git a/manifest.yml b/manifest.yml new file mode 100644 index 0000000..19a5de6 --- /dev/null +++ b/manifest.yml @@ -0,0 +1,9 @@ +--- +applications: + - name: definma + path: dist/UI + buildpacks: + - staticfile_buildpack + memory: 128M + instances: 1 + stack: cflinuxfs3 diff --git a/package-lock.json b/package-lock.json index a2bffa1..7d7b150 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7ec9656..9941ad5 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/Staticfile b/src/Staticfile new file mode 100644 index 0000000..1b9f882 --- /dev/null +++ b/src/Staticfile @@ -0,0 +1,4 @@ +pushstate: enabled +force_https: true +root: UI +location_include: custom-header.conf diff --git a/src/app/api.service.spec.ts b/src/app/api.service.spec.ts deleted file mode 100644 index 615a2fc..0000000 --- a/src/app/api.service.spec.ts +++ /dev/null @@ -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; -let localStorageServiceSpy: jasmine.SpyObj; - -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; - localStorageServiceSpy = TestBed.inject(LocalStorageService) as jasmine.SpyObj; - }); - - 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'); - }); -}); diff --git a/src/app/api.service.ts b/src/app/api.service.ts deleted file mode 100644 index 6183512..0000000 --- a/src/app/api.service.ts +++ /dev/null @@ -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 {}; - } - } -} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 56965a3..d3071c5 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -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: '' } diff --git a/src/app/app.component.html b/src/app/app.component.html index e31308e..eadcb78 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,9 +1,27 @@ -
Digital Fingerprint of Plastics
+ + + + +
+

+ +

+ + +
+
+
+ +
DEVELOPMENTDigital Fingerprint of Plastics
diff --git a/src/app/app.component.scss b/src/app/app.component.scss index e69de29..31ad797 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -0,0 +1,5 @@ +.dev-label { + color: #F00; + font-size: 32px; + margin-right: 40px; +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 037d7a8..8105dfb 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -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; 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; })); beforeEach(() => { diff --git a/src/app/app.component.ts b/src/app/app.component.ts index b997e1a..fc074c0 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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(['/']); + } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index bed1712..4fd6a1c 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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 { } diff --git a/src/app/documentation/documentation.component.html b/src/app/documentation/documentation.component.html new file mode 100644 index 0000000..3ff12b0 --- /dev/null +++ b/src/app/documentation/documentation.component.html @@ -0,0 +1,9 @@ +

Samples

+ +

+ Find the API documentation here: https://definma-api.apps.de1.bosch-iot-cloud.com/api-doc/ +

+ +

Database model

+ + diff --git a/src/app/documentation/documentation.component.scss b/src/app/documentation/documentation.component.scss new file mode 100644 index 0000000..58a9ed8 --- /dev/null +++ b/src/app/documentation/documentation.component.scss @@ -0,0 +1,7 @@ +p { + margin-bottom: 20px; +} + +img#db-structure { + width: 100%; +} diff --git a/src/app/documentation/documentation.component.spec.ts b/src/app/documentation/documentation.component.spec.ts new file mode 100644 index 0000000..fec102e --- /dev/null +++ b/src/app/documentation/documentation.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ DocumentationComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DocumentationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/documentation/documentation.component.ts b/src/app/documentation/documentation.component.ts new file mode 100644 index 0000000..c3f6071 --- /dev/null +++ b/src/app/documentation/documentation.component.ts @@ -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 { + } + +} diff --git a/src/app/error/error.component.html b/src/app/error/error.component.html new file mode 100644 index 0000000..5a16414 --- /dev/null +++ b/src/app/error/error.component.html @@ -0,0 +1,3 @@ + + {{message}} + diff --git a/src/app/error/error.component.scss b/src/app/error/error.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/error/error.component.spec.ts b/src/app/error/error.component.spec.ts new file mode 100644 index 0000000..34f1cc0 --- /dev/null +++ b/src/app/error/error.component.spec.ts @@ -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; + 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'); + }); +}); diff --git a/src/app/error/error.component.ts b/src/app/error/error.component.ts new file mode 100644 index 0000000..fe0940d --- /dev/null +++ b/src/app/error/error.component.ts @@ -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 { + } + +} diff --git a/src/app/exists.pipe.spec.ts b/src/app/exists.pipe.spec.ts new file mode 100644 index 0000000..110c53c --- /dev/null +++ b/src/app/exists.pipe.spec.ts @@ -0,0 +1,8 @@ +import { ExistsPipe } from './exists.pipe'; + +describe('ExistsPipe', () => { + it('create an instance', () => { + const pipe = new ExistsPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/src/app/exists.pipe.ts b/src/app/exists.pipe.ts new file mode 100644 index 0000000..2fefbd7 --- /dev/null +++ b/src/app/exists.pipe.ts @@ -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) : ''; + } + +} diff --git a/src/app/img-magnifier/img-magnifier.component.html b/src/app/img-magnifier/img-magnifier.component.html new file mode 100644 index 0000000..a547720 --- /dev/null +++ b/src/app/img-magnifier/img-magnifier.component.html @@ -0,0 +1,17 @@ +
+
+
+ +
diff --git a/src/app/img-magnifier/img-magnifier.component.scss b/src/app/img-magnifier/img-magnifier.component.scss new file mode 100644 index 0000000..f72791e --- /dev/null +++ b/src/app/img-magnifier/img-magnifier.component.scss @@ -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; +} diff --git a/src/app/img-magnifier/img-magnifier.component.spec.ts b/src/app/img-magnifier/img-magnifier.component.spec.ts new file mode 100644 index 0000000..753cad6 --- /dev/null +++ b/src/app/img-magnifier/img-magnifier.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ImgMagnifierComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ImgMagnifierComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/img-magnifier/img-magnifier.component.ts b/src/app/img-magnifier/img-magnifier.component.ts new file mode 100644 index 0000000..5a339db --- /dev/null +++ b/src/app/img-magnifier/img-magnifier.component.ts @@ -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'; + } +} diff --git a/src/app/login/login.component.html b/src/app/login/login.component.html index 593bffc..e765fc9 100644 --- a/src/app/login/login.component.html +++ b/src/app/login/login.component.html @@ -1,10 +1,15 @@ diff --git a/src/app/login/login.component.scss b/src/app/login/login.component.scss index f5a95d1..52566ab 100644 --- a/src/app/login/login.component.scss +++ b/src/app/login/login.component.scss @@ -8,5 +8,5 @@ } .login-button { - display: block; + margin-right: 10px; } diff --git a/src/app/login/login.component.spec.ts b/src/app/login/login.component.spec.ts index 220c313..5922456 100644 --- a/src/app/login/login.component.spec.ts +++ b/src/app/login/login.component.spec.ts @@ -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; let loginServiceSpy: jasmine.SpyObj; @@ -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!'); })); }); diff --git a/src/app/login/login.component.ts b/src/app/login/login.component.ts index 2bc6ad0..6c28dcc 100644 --- a/src/app/login/login.component.ts +++ b/src/app/login/login.component.ts @@ -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 - } - else { - this.message = 'Wrong credentials! Try again.'; - } - }); - } + this.loginService.login(this.username, this.password).then(ok => { + if (ok) { + this.message = 'Login successful'; + this.router.navigate(['/samples']); + } + else { + this.message = 'Wrong credentials!'; + } + }); } - } diff --git a/src/app/models/base.model.spec.ts b/src/app/models/base.model.spec.ts new file mode 100644 index 0000000..4bbedf0 --- /dev/null +++ b/src/app/models/base.model.spec.ts @@ -0,0 +1,7 @@ +import { BaseModel } from './base.model'; + +describe('BaseModel', () => { + it('should create an instance', () => { + expect(new BaseModel()).toBeTruthy(); + }); +}); diff --git a/src/app/models/base.model.ts b/src/app/models/base.model.ts new file mode 100644 index 0000000..587913d --- /dev/null +++ b/src/app/models/base.model.ts @@ -0,0 +1,10 @@ +export class BaseModel { + deserialize(input: any): this { + Object.assign(this, input); + return this; + } + + sendFormat(): this { + return this; + } +} diff --git a/src/app/models/custom-fields.model.spec.ts b/src/app/models/custom-fields.model.spec.ts new file mode 100644 index 0000000..4b2d5d2 --- /dev/null +++ b/src/app/models/custom-fields.model.spec.ts @@ -0,0 +1,7 @@ +import { CustomFieldsModel } from './custom-fields.model'; + +describe('CustomFieldsModel', () => { + it('should create an instance', () => { + expect(new CustomFieldsModel()).toBeTruthy(); + }); +}); diff --git a/src/app/models/custom-fields.model.ts b/src/app/models/custom-fields.model.ts new file mode 100644 index 0000000..7f20193 --- /dev/null +++ b/src/app/models/custom-fields.model.ts @@ -0,0 +1,7 @@ +import {BaseModel} from './base.model'; + + +export class CustomFieldsModel extends BaseModel { + name = ''; + qty = 0; +} diff --git a/src/app/models/id.model.ts b/src/app/models/id.model.ts new file mode 100644 index 0000000..a945ecc --- /dev/null +++ b/src/app/models/id.model.ts @@ -0,0 +1 @@ +export type IdModel = string | null; diff --git a/src/app/models/material.model.spec.ts b/src/app/models/material.model.spec.ts new file mode 100644 index 0000000..6be3590 --- /dev/null +++ b/src/app/models/material.model.spec.ts @@ -0,0 +1,7 @@ +import { MaterialModel } from './material.model'; + +describe('MaterialModel', () => { + it('should create an instance', () => { + expect(new MaterialModel()).toBeTruthy(); + }); +}); diff --git a/src/app/models/material.model.ts b/src/app/models/material.model.ts new file mode 100644 index 0000000..30e37f6 --- /dev/null +++ b/src/app/models/material.model.ts @@ -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']); + } +} diff --git a/src/app/models/measurement.model.spec.ts b/src/app/models/measurement.model.spec.ts new file mode 100644 index 0000000..2a96f8f --- /dev/null +++ b/src/app/models/measurement.model.spec.ts @@ -0,0 +1,7 @@ +import { MeasurementModel } from './measurement.model'; + +describe('MeasurementModel', () => { + it('should create an instance', () => { + expect(new MeasurementModel()).toBeTruthy(); + }); +}); diff --git a/src/app/models/measurement.model.ts b/src/app/models/measurement.model.ts new file mode 100644 index 0000000..4539430 --- /dev/null +++ b/src/app/models/measurement.model.ts @@ -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); + } +} diff --git a/src/app/models/sample.model.spec.ts b/src/app/models/sample.model.spec.ts new file mode 100644 index 0000000..2959c9a --- /dev/null +++ b/src/app/models/sample.model.spec.ts @@ -0,0 +1,7 @@ +import { SampleModel } from './sample.model'; + +describe('SampleModel', () => { + it('should create an instance', () => { + expect(new SampleModel()).toBeTruthy(); + }); +}); diff --git a/src/app/models/sample.model.ts b/src/app/models/sample.model.ts new file mode 100644 index 0000000..319af04 --- /dev/null +++ b/src/app/models/sample.model.ts @@ -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']); + } +} diff --git a/src/app/models/template.model.spec.ts b/src/app/models/template.model.spec.ts new file mode 100644 index 0000000..39913ae --- /dev/null +++ b/src/app/models/template.model.spec.ts @@ -0,0 +1,7 @@ +import { TemplateModel } from './template.model'; + +describe('TemplateModel', () => { + it('should create an instance', () => { + expect(new TemplateModel()).toBeTruthy(); + }); +}); diff --git a/src/app/models/template.model.ts b/src/app/models/template.model.ts new file mode 100644 index 0000000..c6d7099 --- /dev/null +++ b/src/app/models/template.model.ts @@ -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}}[] = []; +} diff --git a/src/app/object.pipe.spec.ts b/src/app/object.pipe.spec.ts new file mode 100644 index 0000000..5560af0 --- /dev/null +++ b/src/app/object.pipe.spec.ts @@ -0,0 +1,8 @@ +import { ObjectPipe } from './object.pipe'; + +describe('ObjectPipe', () => { + it('create an instance', () => { + const pipe = new ObjectPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/src/app/object.pipe.ts b/src/app/object.pipe.ts new file mode 100644 index 0000000..7dc3e50 --- /dev/null +++ b/src/app/object.pipe.ts @@ -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) : ''; + } + +} diff --git a/src/app/rb-table/rb-table/rb-table.component.html b/src/app/rb-table/rb-table/rb-table.component.html index 9ced8a1..5e3c6a4 100644 --- a/src/app/rb-table/rb-table/rb-table.component.html +++ b/src/app/rb-table/rb-table/rb-table.component.html @@ -1,3 +1,6 @@ - - -
+ +
+ + +
+
diff --git a/src/app/rb-table/rb-table/rb-table.component.scss b/src/app/rb-table/rb-table/rb-table.component.scss index 80a2419..c1b1e0a 100644 --- a/src/app/rb-table/rb-table/rb-table.component.scss +++ b/src/app/rb-table/rb-table/rb-table.component.scss @@ -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 { diff --git a/src/app/sample/sample.component.html b/src/app/sample/sample.component.html new file mode 100644 index 0000000..7401416 --- /dev/null +++ b/src/app/sample/sample.component.html @@ -0,0 +1,159 @@ +

{{new ? 'Add new sample' : 'Edit sample ' + sample.number}}

+ + + +
+ +
+
+ + Cannot be empty + Unknown material, add properties for new material + + +
+ +
+

Material properties

+ + {{supplierInput.errors.failure}} + + + {{groupInput.errors.failure}} + +
+ +
+ + + + + {{parameterInput.errors.failure}} + Cannot be empty + +
+ +   + +
+ + {{typeInput.errors.failure}} + Cannot be empty + + + {{colorInput.errors.failure}} + Cannot be empty + + + {{batchInput.errors.failure}} + +
+
+ +
+ + {{commentInput.errors.failure}} + +
Sample references
+
+
+ + Unknown sample number + +
+ + Cannot be empty + +
+
Additional properties
+
+
+ + {{keyInput.errors.failure}} + +
+ + Cannot be empty + +
+
+ +   + +
+

+ Condition + +

+
+ + + + + + {{parameterInput.errors.failure}} + Cannot be empty + +
+
+ +   + +
+

Measurements

+
+ + + + +
+ + {{parameterInput.errors.failure}} + Cannot be empty + + + Cannot be empty + + + +
+ + +
+ +   + +
+ +
+
+ + +
+ +
+
+ +
+

Successfully added sample:

+ + Sample number{{responseData.number}} + Type{{responseData.type}} + color{{responseData.color}} + Batch{{responseData.batch}} + Material{{material.name}} + + +   + + +
diff --git a/src/app/sample/sample.component.scss b/src/app/sample/sample.component.scss new file mode 100644 index 0000000..8b4e416 --- /dev/null +++ b/src/app/sample/sample.component.scss @@ -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; +} diff --git a/src/app/sample/sample.component.spec.ts b/src/app/sample/sample.component.spec.ts new file mode 100644 index 0000000..c32c4c1 --- /dev/null +++ b/src/app/sample/sample.component.spec.ts @@ -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; +// +// beforeEach(async(() => { +// TestBed.configureTestingModule({ +// declarations: [ SampleComponent ] +// }) +// .compileComponents(); +// })); +// +// beforeEach(() => { +// fixture = TestBed.createComponent(SampleComponent); +// component = fixture.componentInstance; +// fixture.detectChanges(); +// }); +// +// it('should create', () => { +// expect(component).toBeTruthy(); +// }); +// }); diff --git a/src/app/sample/sample.component.ts b/src/app/sample/sample.component.ts new file mode 100644 index 0000000..5f68668 --- /dev/null +++ b/src/app/sample/sample.component.ts @@ -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('/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('/material/suppliers', (data: any) => { + this.suppliers = data; + this.loading--; + }); + this.api.get('/material/groups', (data: any) => { + this.groups = data; + this.loading--; + }); + this.api.get('/template/conditions', data => { + this.conditionTemplates = data.map(e => new TemplateModel().deserialize(e)); + this.loading--; + }); + this.api.get('/template/materials', data => { + this.materialTemplates = data.map(e => new TemplateModel().deserialize(e)); + this.selectMaterialTemplate(this.materialTemplates[0]._id); + this.loading--; + }); + this.api.get('/template/measurements', data => { + this.measurementTemplates = data.map(e => new TemplateModel().deserialize(e)); + if (!this.new) { + this.loading++; + this.api.get('/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('/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('/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(resolve => { + if (this.newMaterial) { // save material first if new one exists + this.material.numbers = this.material.numbers.filter(e => e !== ''); + this.api.post('/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(resolve => { + if (this.new) { + this.api.post('/sample/new', this.sample.sendFormat(), resolve); + } + else { + this.api.put('/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('/measurement/new', measurement.sendFormat()); + } + else { // update measurement + this.api.put('/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^^ diff --git a/src/app/samples/samples.component.html b/src/app/samples/samples.component.html index 00c2768..1e453bb 100644 --- a/src/app/samples/samples.component.html +++ b/src/app/samples/samples.component.html @@ -1,41 +1,130 @@

Samples

- + + +
-   Filter +   Filter - Not implemented (yet) +
+
+ + + validated + + + new + +
+ + + + + + + + + + + + {{item.label}} + + +
+
+ + + + + + + + + + + + + +
+ + + + + + + +
+
+
+
+
+ + + +
+ + + URL for JSON download: + + + add spectra + + + + + + +
+ - Number - Material number - Material name - Supplier - Material - GF - CF - M - type - Color - Batch + +
+ {{key.label}} + + +
+ + - {{sample.number}} - {{sample.material_number}} - {{materials[sample.material_id].name}} - {{materials[sample.material_id].supplier}} - {{materials[sample.material_id].group}} - {{materials[sample.material_id].glass_fiber}} - {{materials[sample.material_id].carbon_fiber}} - {{materials[sample.material_id].mineral}} - {{sample.type}} - {{sample.color}} - {{sample.batch}} + {{sample.number}} + {{materials[sample.material_id].numbers}} + {{materials[sample.material_id].name}} + {{materials[sample.material_id].supplier}} + {{materials[sample.material_id].group}} + {{materials[sample.material_id].properties[key[2]] | exists}} + {{sample.type}} + {{sample.color}} + {{sample.batch}} + {{sample.notes | object}} + {{sample[key[1]] | exists: key[2]}} + {{sample.added | date:'dd/MM/yy'}} +
+ + + + +
+ + + + of {{pages}} ({{totalSamples}} samples) + + +
+
+ diff --git a/src/app/samples/samples.component.scss b/src/app/samples/samples.component.scss index 4d9fd1d..55d78fd 100644 --- a/src/app/samples/samples.component.scss +++ b/src/app/samples/samples.component.scss @@ -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; +} diff --git a/src/app/samples/samples.component.spec.ts b/src/app/samples/samples.component.spec.ts index 2f3cdc5..1964449 100644 --- a/src/app/samples/samples.component.spec.ts +++ b/src/app/samples/samples.component.spec.ts @@ -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; - - 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; +// +// beforeEach(async(() => { +// TestBed.configureTestingModule({ +// declarations: [ SamplesComponent ] +// }) +// .compileComponents(); +// })); +// +// beforeEach(() => { +// fixture = TestBed.createComponent(SamplesComponent); +// component = fixture.componentInstance; +// fixture.detectChanges(); +// }); +// +// it('should create', () => { +// expect(component).toBeTruthy(); +// }); +// }); diff --git a/src/app/samples/samples.component.ts b/src/app/samples/samples.component.ts index df3327e..0aa5c89 100644 --- a/src/app/samples/samples.component.ts +++ b/src/app/samples/samples.component.ts @@ -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; + @ViewChild('linkarea') linkarea: ElementRef; + + 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('/material/suppliers', (data: any) => { + this.filters.filters.find(e => e.field === 'material.supplier').autocomplete = data; + }); + this.api.get('/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); + } } diff --git a/src/app/services/api.service.spec.ts b/src/app/services/api.service.spec.ts new file mode 100644 index 0000000..3782e9a --- /dev/null +++ b/src/app/services/api.service.spec.ts @@ -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; +let localStorageServiceSpy: jasmine.SpyObj; +let modalServiceSpy: jasmine.SpyObj; + +// 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; + localStorageServiceSpy = TestBed.inject(LocalStorageService) as jasmine.SpyObj; + modalServiceSpy = TestBed.inject(ModalService) as jasmine.SpyObj; + }); + + 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 +}); diff --git a/src/app/services/api.service.ts b/src/app/services/api.service.ts new file mode 100644 index 0000000..60780d9 --- /dev/null +++ b/src/app/services/api.service.ts @@ -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(url, f: (data?: T, err?, headers?) => void = () => {}) { + this.requestErrorHandler(this.http.get(this.host + url, this.options()), f); + } + + post(url, data = null, f: (data?: T, err?, headers?) => void = () => {}) { + this.requestErrorHandler(this.http.post(this.host + url, data, this.options()), f); + } + + put(url, data = null, f: (data?: T, err?, headers?) => void = () => {}) { + this.requestErrorHandler(this.http.put(this.host + url, data, this.options()), f); + } + + delete(url, f: (data?: T, err?, headers?) => void = () => {}) { + this.requestErrorHandler(this.http.delete(this.host + url, this.options()), f); + } + + private requestErrorHandler(observable: Observable, 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(); + } + } +} diff --git a/src/app/services/autocomplete.service.spec.ts b/src/app/services/autocomplete.service.spec.ts new file mode 100644 index 0000000..ef9aae6 --- /dev/null +++ b/src/app/services/autocomplete.service.spec.ts @@ -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([]); + }); + }); +}); diff --git a/src/app/services/autocomplete.service.ts b/src/app/services/autocomplete.service.ts new file mode 100644 index 0000000..4000b67 --- /dev/null +++ b/src/app/services/autocomplete.service.ts @@ -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)); + } +} diff --git a/src/app/login.service.spec.ts b/src/app/services/login.service.spec.ts similarity index 72% rename from src/app/login.service.spec.ts rename to src/app/services/login.service.spec.ts index ededdbe..d7891fe 100644 --- a/src/app/login.service.spec.ts +++ b/src/app/services/login.service.spec.ts @@ -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; @@ -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(); + }); }); }); }); diff --git a/src/app/login.service.ts b/src/app/services/login.service.ts similarity index 59% rename from src/app/login.service.ts rename to src/app/services/login.service.ts index cfa1308..088792a 100644 --- a/src/app/login.service.ts +++ b/src/app/services/login.service.ts @@ -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 { + return new Observable(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]; + } } diff --git a/src/app/services/validation.service.spec.ts b/src/app/services/validation.service.spec.ts new file mode 100644 index 0000000..67f8108 --- /dev/null +++ b/src/app/services/validation.service.spec.ts @@ -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'}); + }); +}); diff --git a/src/app/services/validation.service.ts b/src/app/services/validation.service.ts new file mode 100644 index 0000000..229c967 --- /dev/null +++ b/src/app/services/validation.service.ts @@ -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: ''}; + } +} diff --git a/src/app/validate.directive.spec.ts b/src/app/validate.directive.spec.ts new file mode 100644 index 0000000..9489d0d --- /dev/null +++ b/src/app/validate.directive.spec.ts @@ -0,0 +1,8 @@ +// import { ValidateDirective } from './validate.directive'; +// +// describe('ValidateDirective', () => { +// it('should create an instance', () => { +// const directive = new ValidateDirective(); +// expect(directive).toBeTruthy(); +// }); +// }); diff --git a/src/app/validate.directive.ts b/src/app/validate.directive.ts new file mode 100644 index 0000000..7b8a2ec --- /dev/null +++ b/src/app/validate.directive.ts @@ -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; + + 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 }; + } +} diff --git a/src/app/validation.service.spec.ts b/src/app/validation.service.spec.ts deleted file mode 100644 index e931a6f..0000000 --- a/src/app/validation.service.spec.ts +++ /dev/null @@ -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!"#%&\'()*+,-./:;<=>?@[]^_`{|}~'}); - }); - -}); diff --git a/src/app/validation.service.ts b/src/app/validation.service.ts deleted file mode 100644 index bc2d41b..0000000 --- a/src/app/validation.service.ts +++ /dev/null @@ -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: ''}; - } -} diff --git a/src/assets/imgs/db_structure_latest.svg b/src/assets/imgs/db_structure_latest.svg new file mode 100644 index 0000000..5dccaf4 --- /dev/null +++ b/src/assets/imgs/db_structure_latest.svg @@ -0,0 +1,3 @@ + + +
measurements
measurements
materials
materials
samples
samples
_id
_id
_id
_id
values
values
_id
_id
number
number
numbers
numbers
material_name
material_name
batch
batch
comment
comment
location
location
models
models
name
name
status
status
notes
notes
data
data
_id
_id
users
users
name
name
pass
pass
level
level
_id
_id
type
type
measurement_templates
measurement_templates
_id
_id
parameters
parameters
range
range
name
name
name
name
color
color
sample_references
sample_references
device_name
device_name
condition_templates
condition_templates
_id
_id
parameters
parameters
range
range
name
name
name
name
custom fields
custom fields
key
key
email
email
note_fields
note_fields
name
name
qty
qty
sample_id
sample_id
relation
relation
version
version
version
version
status
status
status
status
condition
condition
parameters
parameters
material_groups
material_groups
material_suppliers
material_suppliers
_id
_id
name
name
_id
_id
name
name
first_id
first_id
first_id
first_id
changelog
changelog
_id
_id
collection
collection
data
data
condition
condition
action
action
material_templates
material_templates
_id
_id
parameters
parameters
range
range
name
name
name
name
version
version
first_id
first_id
properties
properties
parameters
parameters
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 30956ae..08610e4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ }, "angularCompilerOptions": { "fullTemplateTypeCheck": true, - "strictInjectionParameters": true + "strictInjectionParameters": true, + "debug": true } } diff --git a/tslint.json b/tslint.json index d97b386..a1e1fa5 100644 --- a/tslint.json +++ b/tslint.json @@ -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"