import cloneDeep from 'lodash/cloneDeep'; import merge from 'lodash/merge'; import omit from 'lodash/omit'; import pick from 'lodash/pick'; import isEqual from 'lodash/isEqual'; import strCompare from 'str-compare'; import { AfterContentChecked, Component, OnInit, TemplateRef, 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 {MeasurementModel} from '../models/measurement.model'; import { ChartOptions } from 'chart.js'; import {animate, style, transition, trigger} from '@angular/animations'; import {Observable} from 'rxjs'; import {ModalService} from '@inst-iot/bosch-angular-ui-components'; import {DataService} from '../services/data.service'; import {LoginService} from '../services/login.service'; // TODO: additional property value not validated on edit @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; @ViewChild('cmForm') cmForm: NgForm; baseSample = new SampleModel(); // base sample which is saved sampleCount = 1; // number of samples to be generated samples: SampleModel[] = []; // gets filled with response data after saving the sample sampleReferences: [string, string, string][] = [['', '', '']]; sampleReferenceFinds: {_id: string, number: string}[] = []; // raw sample reference data from db currentSRIndex = 0; // index of last entered sample reference sampleReferenceAutocomplete: string[][] = [[]]; customFields: [string, string][] = []; availableCustomFields: string[] = []; newMaterial = false; // true if new material should be created materials: MaterialModel[] = []; // all materials materialNames = []; // only names for autocomplete material = new MaterialModel(); // object of current selected material defaultDevice = ''; // default device for spectra // component mode, either new for generating new samples, editOne or editMulti, editing one or multiple samples mode = 'new'; view = { // active views base: false, // base sample baseSum: false, // base sample summary cm: false, // conditions and measurements cmSum: false // conditions and measurements summary }; loading = 0; // number of currently loading instances checkFormAfterInit = false; modalText = {list: '', suggestion: ''}; cmSampleIndex = '0'; measurementDeleteList = []; // buffer with measurements to delete, if the user confirms and saves the cm changes measurementRestoreData: MeasurementModel[] = []; // deleted measurements if user is allowed and measurements are available charts = [[]]; // chart data for spectra 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, private modal: ModalService, public d: DataService, private login: LoginService ) { } ngOnInit(): void { this.mode = this.router.url === '/samples/new' ? 'new' : ''; this.loading = 7; this.d.load('materials', () => { this.materialNames = this.d.arr.materials.map(e => e.name); this.loading--; }); this.d.load('materialSuppliers', () => { this.loading--; }); this.d.load('materialGroups', () => { this.loading--; }); this.d.load('conditionTemplates', () => { this.loading--; }); this.d.load('measurementTemplates', () => { this.d.load('user', () => { this.defaultDevice = this.d.d.user.devices[0]; // spectrum device must be from user's devices list this.d.arr.measurementTemplates.forEach(template => { const device = template.parameters.find(e => e.name === 'device'); if (device) { device.range.values = this.d.d.user.devices; } }); this.d.idReload('measurementTemplates'); }); this.loading--; }); this.d.load('materialTemplates', () => { if (!this.material.properties.material_template) { this.material.properties.material_template = this.d.latest.materialTemplates.find(e => e.name === 'plastic')._id; } this.loading--; }); this.d.load('sampleNotesFields', () => { this.availableCustomFields = this.d.arr.sampleNotesFields.map(e => e.name); this.loading--; }); if (this.mode !== 'new') { const sampleIds = this.route.snapshot.paramMap.get('id').split(','); if (sampleIds.length === 1) { this.mode = 'editOne'; this.view.baseSum = true; this.view.cm = true; if (this.login.isLevel.dev) { // load measurement restore data this.api.get('/measurement/sample/' + sampleIds[0], (data, ignore) => { if (data) { this.measurementRestoreData = data.filter(e => e.status === 'deleted').map(e => new MeasurementModel().deserialize(e)); } console.log(this.measurementRestoreData); }); } } else { this.mode = 'editMulti'; this.view.base = true; } this.loading += sampleIds.length; this.api.get('/sample/' + sampleIds[0], sData => { // special treatment for first id this.samples = [new SampleModel().deserialize(sData)]; this.baseSample.deserialize(sData); this.material = new MaterialModel().deserialize(sData.material); // read material this.customFields = this.baseSample.notes.custom_fields && this.baseSample.notes.custom_fields !== {} ? // read custom fields Object.keys(this.baseSample.notes.custom_fields).map(e => [e, this.baseSample.notes.custom_fields[e]]) : []; if (this.baseSample.notes.sample_references.length) { // read sample references this.sampleReferences = []; this.sampleReferenceAutocomplete = []; let loadCounter = this.baseSample.notes.sample_references.length; // count down instances still loading this.baseSample.notes.sample_references.forEach(reference => { this.api.get('/sample/' + reference.sample_id, srData => { // get sample numbers for ids this.sampleReferences.push([srData.number, reference.relation, reference.sample_id]); this.sampleReferenceAutocomplete.push([srData.number]); if (!--loadCounter) { // insert empty template when all instances were loaded this.sampleReferences.push(['', '', '']); this.sampleReferenceAutocomplete.push([]); } }); }); } if (this.mode === 'editOne') { this.charts = [[]]; let spectrumCounter = 0; // generate charts for spectrum measurements this.samples[0].measurements.forEach((measurement, i) => { this.charts[0].push(cloneDeep(this.chartInit)); if (measurement.values.dpt) { setTimeout(() => { this.generateChart(measurement.values.dpt, 0, i); }, spectrumCounter * 20); // generate charts one after another to avoid freezing the UI spectrumCounter ++; } }); } this.checkFormAfterInit = true; this.loading--; sampleIds.slice(1).forEach(sampleId => { this.api.get('/sample/' + sampleId, data => { this.samples.push(new SampleModel().deserialize(data)); ['type', 'color', 'batch', 'notes'].forEach((key) => { console.log(isEqual(data[key], this.baseSample[key])); if (!isEqual(data[key], this.baseSample[key])) { this.baseSample[key] = undefined; } }); if (!isEqual(data.material.name, this.baseSample.material.name)) { this.baseSample.material.name = undefined; } this.loading--; this.checkFormAfterInit = true; console.log(this.baseSample.material.name); }); }); }); } else { this.view.base = true; } } ngAfterContentChecked() { if (this.samples.length) { // conditions are displayed this.samples.forEach((gSample, gIndex) => { if (this.d.id.conditionTemplates[gSample.condition.condition_template]) { this.d.id.conditionTemplates[gSample.condition.condition_template].parameters.forEach((parameter, pIndex) => { this.attachValidator(this.cmForm, `conditionParameter-${gIndex}-${pIndex}`, parameter.range); }); } gSample.measurements.forEach((measurement, mIndex) => { this.d.id.measurementTemplates[measurement.measurement_template].parameters.forEach((parameter, pIndex) => { this.attachValidator(this.cmForm, `measurementParameter-${gIndex}-${mIndex}-${pIndex}`, parameter.range); }); }); }); } if (this.sampleForm && this.material.properties.material_template) { // material template is set this.d.id.materialTemplates[this.material.properties.material_template].parameters.forEach((parameter, i) => { this.attachValidator(this.sampleForm, 'materialParameter' + i, parameter.range); }); } if (this.checkFormAfterInit) { if (this.view.base) { // validate sampleForm if (this.sampleForm !== undefined && this.sampleForm.form.get('cf-key0')) { this.checkFormAfterInit = false; Object.keys(this.sampleForm.form.controls).forEach(field => { this.sampleForm.form.get(field).updateValueAndValidity(); }); } } else { // validate cmForm // check that all fields are ready for validation let formReady: boolean = this.cmForm !== undefined; // forms exist if (this.samples[0].condition.condition_template) { // if condition is set, last condition field exists formReady = formReady && this.cmForm.form.get('conditionParameter-0-' + (this.d.id.conditionTemplates[this.samples[0].condition.condition_template].parameters.length - 1)) as any; } if (this.samples[0].measurements.length) { // if there are measurements, last measurement field exists formReady = formReady && this.cmForm.form.get('measurementParameter-0-' + (this.samples[0].measurements.length - 1) + '-' + (this.d.id.measurementTemplates[this.samples[0].measurements[this.samples[0].measurements.length - 1] .measurement_template].parameters.length - 1)) as any; } if (formReady) { // fields are ready, do validation this.checkFormAfterInit = false; console.log('init'); Object.keys(this.cmForm.form.controls).forEach(field => { this.cmForm.form.get(field).updateValueAndValidity(); }); } } } } // attach validators specified in range to input with name attachValidator(form, name: string, range: {[prop: string]: any}) { if (form && form.form.get(name)) { const validators = []; if (range.hasOwnProperty('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])); } form.form.get(name).setValidators(validators); } } reValidate() { setTimeout(() => this.checkFormAfterInit = true, 0); } // save base sample saveSample() { if (this.samples.length === 0) { this.loading = this.sampleCount; // set up loading spinner } 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.d.arr.materials.push(data); // add material to data this.material = data; this.baseSample.material_id = data._id; // add new material id to sample data resolve(); }); } else { resolve(); } }).then(() => { // save sample if (this.baseSample.notes) { this.baseSample.notes.custom_fields = {}; this.customFields.forEach(element => { if (element[0] !== '') { this.baseSample.notes.custom_fields[element[0]] = element[1]; } }); this.baseSample.notes.sample_references = this.sampleReferences .filter(e => e[0] && e[1] && e[2]) .map(e => ({sample_id: e[2], relation: e[1]})); } if (this.samples.length === 0) { // only save new sample for the first time in mode new, otherwise save changes for (let i = 0; i < this.sampleCount; i ++) { this.api.post('/sample/new', this.baseSample.sendFormat(), data => { this.samples[i] = new SampleModel().deserialize(data); this.samples[i].material = this.d.arr.materials.find(e => e._id === this.samples[i].material_id); this.loading --; }); } this.view.base = false; this.view.baseSum = true; this.view.cm = true; } else { this.samples.forEach((sample, i) => { console.log(sample._id); this.api.put('/sample/' + sample._id, this.baseSample.sendFormat(false), data => { merge(this.samples[i], omit(data, ['condition'])); this.samples[i].material = this.d.arr.materials.find(e => e._id === this.samples[0].material_id); this.view.base = false; this.view.baseSum = true; }); }); } }); } // save conditions and measurements cmSave() { // save measurements and conditions this.samples.forEach(sample => { if (sample.condition.condition_template) { // condition was set console.log(sample.condition); console.log(this.d.id.conditionTemplates[sample.condition.condition_template]); this.api.put('/sample/' + sample._id, {condition: pick(sample.condition, ['condition_template', ...this.d.id.conditionTemplates[sample.condition.condition_template].parameters.map(e => e.name)] )} ); } sample.measurements.forEach(measurement => { // save measurements if (Object.keys(measurement.values).map(e => measurement.values[e]).join('') !== '') { Object.keys(measurement.values).forEach(key => { // map empty values to null measurement.values[key] = measurement.values[key] === '' ? null : measurement.values[key]; }); if (measurement._id === null) { // new measurement measurement.sample_id = sample._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); } }); this.measurementDeleteList.forEach(measurement => { this.api.delete('/measurement/' + measurement); }); }); this.router.navigate(['/samples']); } restoreMeasurements() { let spectrumCounter = 0; // generate charts for spectrum measurements const measurementCount = this.samples[0].measurements.length; this.measurementRestoreData.forEach((measurement, i) => { this.api.put('/measurement/restore/' + measurement._id, {}, () => { this.samples[0].measurements.push(measurement); this.charts[0].push(cloneDeep(this.chartInit)); if (measurement.values.dpt) { setTimeout(() => { this.generateChart(measurement.values.dpt, 0, measurementCount + i); }, spectrumCounter * 20); // generate charts one after another to avoid freezing the UI spectrumCounter ++; } this.checkFormAfterInit = true; }); }); } // set material based on found material name findMaterial(name) { const res = this.d.arr.materials.find(e => e.name === name); // search for match if (res) { // material found this.material = cloneDeep(res); this.baseSample.material_id = this.material._id; } else { // no matching material found if (this.baseSample.material_id !== null) { // reset previous match this.material = new MaterialModel(); this.material.properties.material_template = this.d.latest.materialTemplates.find(e => e.name === 'plastic')._id; } this.baseSample.material_id = null; } this.setNewMaterial(); } // set newMaterial, if value === null -> toggle setNewMaterial(value = null) { if (value === null) { // toggle dialog this.newMaterial = !this.baseSample.material_id; } else if (value || (!value && this.baseSample.material_id !== null )) { // set to false only if material already exists this.newMaterial = value; } if (this.newMaterial) { // set validators if dialog is open this.sampleForm.form.get('materialname').setValidators([Validators.required]); } else { // material name must be from list if dialog is closed this.sampleForm.form.get('materialname') .setValidators([Validators.required, this.validation.generate('stringOf', [this.materialNames])]); } this.sampleForm.form.get('materialname').updateValueAndValidity(); } // add a new measurement for generated sample at index addMeasurement(gIndex) { this.samples[gIndex].measurements.push( new MeasurementModel(this.d.latest.measurementTemplates.find(e => e.name === 'spectrum')._id) ); if (!this.charts[gIndex]) { // add array if there are no charts yet this.charts[gIndex] = []; } this.charts[gIndex].push(cloneDeep(this.chartInit)); } // remove the measurement at the specified index removeMeasurement(gIndex, mIndex) { // TODO: do not delete directly but only after confirmation if (this.samples[gIndex].measurements[mIndex]._id !== null) { this.measurementDeleteList.push(this.samples[gIndex].measurements[mIndex]._id); } this.samples[gIndex].measurements.splice(mIndex, 1); this.charts[gIndex].splice(mIndex, 1); } // clear entered measurement data at the specified index due to template change clearMeasurement(gIndex, mIndex) { this.charts[gIndex][mIndex][0].data = []; this.samples[gIndex].measurements[mIndex].values = {}; } fileToArray(files, gIndex, mIndex, parameter) { for (const i in files) { if (files.hasOwnProperty(i)) { const fileReader = new FileReader(); fileReader.onload = () => { let index: number = mIndex; if (Number(i) > 0) { // append further spectra this.addMeasurement(gIndex); index = this.samples[gIndex].measurements.length - 1; } this.samples[gIndex].measurements[index].values.device = this.samples[gIndex].measurements[mIndex].values.device; this.samples[gIndex].measurements[index].values.filename = files[i].name; this.samples[gIndex].measurements[index].values[parameter] = fileReader.result.toString().split('\r\n').map(e => e.split(',')).filter(el => el.length === 2); this.generateChart(this.samples[gIndex].measurements[index].values[parameter], gIndex, index); }; fileReader.readAsText(files[i]); } } } generateChart(spectrum, gIndex, mIndex) { this.charts[gIndex][mIndex][0].data = spectrum.map(e => ({x: parseFloat(e[0]), y: parseFloat(e[1])})); } toggleCondition(sample) { if (sample.condition.condition_template) { sample.condition.condition_template = null; } else { sample.condition.condition_template = this.d.latest.conditionTemplates[0]._id; } } checkTypo(event, list, mKey, modal: TemplateRef) { // user did not click on suggestion and entry is not in list if (!(event.relatedTarget && (event.relatedTarget.className.indexOf('rb-dropdown-item') >= 0 || event.relatedTarget.className.indexOf('close-btn rb-btn rb-passive-link') >= 0)) && this.d.arr[list].indexOf(this.material[mKey]) < 0) { this.modalText.list = mKey; this.modalText.suggestion = this.d.arr[list] // find possible entry from list .map(e => ({v: e, s: strCompare.sorensenDice(e, this.material[mKey])})) .sort((a, b) => b.s - a.s)[0].v; this.modal.open(modal).then(result => { if (result) { // use suggestion this.material[mKey] = this.modalText.suggestion; } }); } } deleteConfirm(modal) { this.modal.open(modal).then(result => { if (result) { this.samples.forEach(sample => { this.api.delete('/sample/' + sample._id); }); this.router.navigate(['/samples']); } }); } 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 => { if (value !== '') { this.api.get<{ _id: string, number: string }[]>( '/samples?status[]=validated&status[]=new&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 => { this.sampleReferenceAutocomplete[this.currentSRIndex] = data.map(e => e.number); this.sampleReferenceFinds = data; observer.next(data.map(e => e.number)); observer.complete(); this.sampleReferenceIdFind(value); }); } else { observer.next([]); observer.complete(); } }); } 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 ? this.customFields.slice(0, index).map(e => e[0]) : []; } preventDefault(event) { if (event.key && event.key === 'Enter' || event.type === 'dragover') { event.preventDefault(); } } } // 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^^