import { Component, OnInit, OnDestroy, DoCheck, ChangeDetectorRef, NgZone, ElementRef, ViewChild } from '@angular/core'; import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { Subscription } from 'rxjs'; import { BomPreviewModel } from './bom-preview.model'; import { TrxMappingData } from './trx-mapping-data'; import { MessageBusService, StoreService, AnalyticsService, SocketsService, DialogService, BomService, AnalyticsEvent, ApiSocketListenAction, BomRegion } from '@arrow/bom/core'; import { FeatureFlagService } from '@arrow/bom/feature-flag'; import { ConfigService } from '@arrow/bom/config'; import { LoggerService } from '@arrow/bom/logger'; import { SettingsService } from '@arrow/bom/shared'; const FILE_SIZE_LIMIT = 3000; @Component({ templateUrl: 'mapping.component.html', styleUrls: ['mapping.component.scss'] }) export class MappingComponent implements OnInit, OnDestroy, DoCheck { bomPreview: BomPreviewModel; mappedCols: any[] = []; offset: number; submitted: boolean = false; initalStateCaptured: boolean = false; headerSubscription: Subscription; createSubscription: Subscription; trxMappingData: TrxMappingData; headerSelection: boolean = true; headerRow: FormControl; dataRow: FormControl; isBomEmpty: boolean; loading = false; bomId: string; isArrow: boolean; mapCpn = true; hasCpnMap: boolean = false; exceedFileLimit: boolean; target: string; mappingError: string = ''; mappingErrorColumnIndexes: number[] = []; paramFileId: string; forceDropdownUpdate: number; bomRegions: BomRegion[]; public showRegionsInMyarrow: boolean; @ViewChild('headerRowWrapper') headerRowWrapperElement: ElementRef; @ViewChild('dataStartRowWrapper') dataStartRowWrapperElement: ElementRef; constructor( private _router: Router, private _route: ActivatedRoute, private _store: StoreService, private _messageBus: MessageBusService, private _bomService: BomService, private _analyticsService: AnalyticsService, private _sockets: SocketsService, private _cdRef: ChangeDetectorRef, private _dialogService: DialogService, private _ffService: FeatureFlagService, private _config: ConfigService, private _translate: TranslateService, private _logger: LoggerService, private _zone: NgZone, private _settingsService: SettingsService, ) { this._route.queryParams.subscribe(params => { this.paramFileId = params['fileId']; }); } ngOnInit(): void { if (this._ffService.maintenance) { this._router.navigate(['/bom/maintenance']); } this.target = this._config.getTarget(); this.isArrow = this._config.isArrowcom(); this.trxMappingData = new TrxMappingData(); this.showRegionsInMyarrow = this._ffService.showregionsinmyarrow; if (this._store.data) { const storeData = this._store.data; this._store.clear(); this._initializeUploadedFileData(storeData); } else { if(this.paramFileId){//If no data in body, we check if file Id is in query param this._bomService.getUploadedFileById(this.paramFileId) .subscribe((data: any)=> { this._initializeUploadedFileData(data); }); } else{ this._router.navigate(['/bom/']); } } } private _initializeUploadedFileData(bomUploadedData: any): void{ this._logger.info(`FileId: ${bomUploadedData.jsonId}`); // A quick way to check if the preview object is empty this.isBomEmpty = Object.keys(bomUploadedData.previews).length === 0 && bomUploadedData.previews.constructor === Object; if (this.isBomEmpty) { setTimeout(() => { this._dialogService.alert( this._translate.instant('An-error-has-occurred'), this._translate.instant('The-file-you-uploaded-did-not-have-any-content') ); this._router.navigate(['/bom/']); }, 0); } else { this._settingsService.populateBomRegionFullName(bomUploadedData.bomRegions); this.bomPreview = new BomPreviewModel(bomUploadedData); this._analyticsService.googleTrackEvent(AnalyticsEvent.CLICK, { category: 'BOM', action: 'Upload File', label: '' }); this._recordMetadataAnalytics(this.bomPreview); this.headerRow = new FormControl(this.bomPreview.headerRow); this.dataRow = new FormControl(this.bomPreview.dataRow); this._cdRef.detectChanges(); this.disableLoader(); } } ngOnDestroy(): void { if (this.headerSubscription) { this.headerSubscription.unsubscribe(); } if (this.createSubscription) { this.createSubscription.unsubscribe(); } } ngDoCheck(): void { if (this.mappedCols.length > 0 && !this.initalStateCaptured) { this.trxMappingData.setInitialState([...this.mappedCols]); this.setInitalState(); } } private _recordMetadataAnalytics(bomPreview: BomPreviewModel): void { this._analyticsService.googleTrackEvent(AnalyticsEvent.CLICK, { category: 'BOM', action: 'mapping file name', label: bomPreview.name }); this._analyticsService.googleTrackEvent(AnalyticsEvent.CLICK, { category: 'BOM', action: 'mapping number of lines', label: bomPreview.numberOfLines }); this._analyticsService.googleTrackEvent(AnalyticsEvent.CLICK, { category: 'BOM', action: 'mapping file size bytes', label: bomPreview.fileSizeBytes }); } onHeaderChanged(item: any) { this._modifyMappedArray(item, this.bomPreview); this.validate(); } private _recordMappedColsAnalytics(): void { this.mappedCols.forEach(col => { this._analyticsService.googleTrackEvent(AnalyticsEvent.CLICK, { category: 'BOM', action: 'mapping column ' + (col.colId > 0 ? 'mappable' : 'custom'), label: col.name }); }); } /** * Modifies the mappedCols array to filter out unwanted/duplicate data. * @param item is an Object containing information about the selected header. */ private _modifyMappedArray(item: any, bomPreview: BomPreviewModel): void { if (item.name === '- -' || item.name === '') return; this.mappedCols = this.mappedCols.filter( mappedCol => mappedCol.col !== item.col ); if (!item.clear) { item['mappings'] = { [bomPreview.sheetName]: item.col }; this.mappedCols.push(item); } } /** * Triggered whenever a 'customHeader' component is instantiated. * Attempts to automatically match headers to a users uploaded file. * @param header Column header from users file. */ attemptMatch(header: string, bomPreview: BomPreviewModel, columnIndex: number): any { for (let i = 0; i < bomPreview.columns.length; i++) { const column = bomPreview.columns[i]; const rgx = new RegExp(column.validator, column.validatorRules); const match = rgx.test(header); if (match) return column; } // Return default 'Clean Mapping' column if no match was found return { name: '- -', custom: '- -', col: columnIndex, required: false, type: 'Custom' }; } /** * Triggered by scroll event to make headers scroll sync with table scroll. */ onScroll($event: any) { this.offset = $event.target.scrollLeft; } setHeaderRowFocus() { this.headerRowWrapperElement?.nativeElement?.classList.add('bom-element-focused'); } unsetHeaderRowFocus() { this.headerRowWrapperElement?.nativeElement?.classList.remove('bom-element-focused'); } /** * Triggered by value change event on 'Header Row' * checks value and updates bomPreview model. */ onHeaderRowChange( value: any, bomPreview: BomPreviewModel, headerSelection: boolean, dataRow: FormControl ): void { value = parseInt(value, 10); // Update value if value exist and it is within constraints if (!!value && value > 0 && value < bomPreview.rows.length) { if (value !== bomPreview.headerRow) this.mappedCols = []; bomPreview.headerRow = value; bomPreview.update(); } // Sync value with model value this.headerRow.setValue(bomPreview.headerRow); // Check to see if rows are overlapping, if so, nudge data row down if (bomPreview.headerRow >= bomPreview.dataRow) { this.onDataRowChange( bomPreview.headerRow + 1, bomPreview, headerSelection, dataRow ); } this._cdRef.detectChanges(); } onWorksheetChange() { this.mappedCols.length = 0; this.bomPreview.headerRow = 1; this.headerRow.setValue(this.bomPreview.headerRow); this.bomPreview.dataRow = 2; this.dataRow.setValue(this.bomPreview.dataRow); this.bomPreview.headers = []; this._cdRef.detectChanges(); this.bomPreview.update(); this.validate(); //TODO is this call required? this._cdRef.detectChanges(); this.exceedFileLimit = this.bomPreview.numberOfLines > FILE_SIZE_LIMIT; } /** * Triggered by value change event on 'Data Start Row' * checks value and updates bomPreview model. */ onDataRowChange( value: any, bomPreview: BomPreviewModel, headerSelection: boolean, dataRow: FormControl ): void { const max = bomPreview.rows.length; const min = headerSelection ? 1 : 0; value = parseInt(value, 10); // Update value if value exist and it is within constraints if (value && value <= max && value > min) bomPreview.dataRow = value; // Sync value with model value dataRow.setValue(bomPreview.dataRow); // Check to see if rows are overlapping, if so, nudge header row up if (bomPreview.dataRow <= bomPreview.headerRow && headerSelection) { this.onHeaderRowChange( bomPreview.dataRow - 1, bomPreview, headerSelection, dataRow ); } } /** * Validates selected header to make sure 'Part Number' is selected and there's no duplicated column names * and sets valid state of the component. */ //TODO: optimize + detect multiple errors at once and associate failed column indexes (to highlight with red) where applicable validate(): any { this.mappingError = ''; this.mappingErrorColumnIndexes = []; if (this.mappedCols.length === 0) return (this.mappingError = 'required'); this.hasCpnMap = !!this.mappedCols.find( (col: any) => col.type === 'Customer_Part_Number' ); for (let i = 0; i < this.mappedCols.length; i++) { if (this.mappedCols[i].required) { this.mappingError = ''; break; } else { this.mappingError = 'required'; } } if (this.mappingError === '') { this.mappingErrorColumnIndexes = this.mappedCols.reduce( (filtered, currentMappedCol: any, currentMappedColIdx, originalMappedCols) => { if (originalMappedCols.filter(col => col.name === currentMappedCol.name).length > 1) { filtered.push(currentMappedCol.col); } return filtered; }, []) || []; if (this.mappingErrorColumnIndexes.length > 0) { this.mappingError = 'duplicated'; } } } /** * Submits and validates the BoM with the current mapped columns. */ submit(bomPreview: BomPreviewModel): void { this._analyticsService.isBomNew = true; this.submitted = true; this.validate(); if (this.mappingError === '') { this.create(bomPreview); } } /** * Creates a BoM and then calls mapBom */ create(bomPreview: BomPreviewModel): void { this._messageBus.to('ch:loader', { payload: true }); this.loading = true; this.trxMappingData.setFinalState([...this.mappedCols]); this.createSubscription = this._bomService .createBom(bomPreview.name) .subscribe( response => { this._analyticsService.googleTrackEvent(AnalyticsEvent.CLICK, { category: 'BOM', action: 'Upload', label: response.payload.bomId }); this.mapBom(response.payload.bomId, bomPreview); sessionStorage.setItem('newBom', '1'); }, error => { this.loading = false; this.disableLoader(); this.showMessage({ message: error }); } ); } /** * Calls mapBom with the returned bomId then saves analytics and navigates to bomView */ mapBom(bomId: string, bomPreview: BomPreviewModel): void { const mapping: string = JSON.stringify(this.mappedCols); this.bomId = bomId; this._sockets.emitAction('process', { jsonId: bomPreview.id, bomId: this.bomId, dataStarts: bomPreview.dataRow - 1, mappingsJson: mapping, sheetName: bomPreview.sheetName, addNewCPNs: this._config.isMyArrow() && this.hasCpnMap && this.mapCpn, bomRegions: bomPreview.bomRegions?.reduce((filtered, bomRegion: BomRegion) => { if (bomRegion.isSelected) { filtered.push(bomRegion.regionName); } return filtered; }, []) || [] // example: ["AC", "EU"] }); this._recordMappedColsAnalytics(); const sub: Subscription = this._analyticsService .track('columnMapping', this.trxMappingData.getMappedData(this.bomId)) .subscribe(() => sub.unsubscribe()); this.listenForSuccess(); } /** * Subscribes to the BOM create success action and handle response. */ listenForSuccess(): void { const sub: Subscription = this._sockets .observeAction(ApiSocketListenAction.BOM_CREATE_SUCCESS) .subscribe(response => { this._zone.run(() => { sub.unsubscribe(); this.disableLoader(); if (response.result) { this._router.navigate(['../view', response.id, 1, 25], { relativeTo: this._route }); } else { this.loading = false; this.showMessage({ title: this._translate.instant('Mapping-error'), message: this._translate.instant( 'There-was-an-error-processing-the-columns-mapping' ) }); } }) }); } /** * Stops loading spinner when api error occurs */ disableLoader(): void { this._messageBus.to('ch:loader', { payload: false }); } /** * Sets the initial state to true after user interaction * or after automatically mapped headers. */ setInitalState(): void { this.initalStateCaptured = true; } /** * Displays user message modal */ showMessage(data: any): void { this._dialogService.alert(data.title, data.message); } /** * Changes value of Header Row by 1 value in either direction */ stepHeaderRowValue( increment: boolean, headerSelection: boolean, headerRow: FormControl, bomPreview: BomPreviewModel, dataRow: FormControl ): void { if (headerSelection) { const newValue = increment ? headerRow.value + 1 : headerRow.value - 1; this.onHeaderRowChange(newValue, bomPreview, headerSelection, dataRow); } } /** * Changes value of Data Row by 1 value in either direction */ stepDataRowValue( increment: boolean, bomPreview: BomPreviewModel, headerSelection: boolean, dataRow: FormControl ): void { const newValue = increment ? dataRow.value + 1 : dataRow.value - 1; this.onDataRowChange(newValue, bomPreview, headerSelection, dataRow); } /** * Toggles the display of the Header Row indecator. */ toggleHeaderRow( bomPreview: BomPreviewModel, headerSelection: boolean, dataRow: FormControl ): void { this.headerSelection = !this.headerSelection; // if header selection gets enabled check to make sure // data row isn't on an invalid row, ie less than header row. if (this.headerSelection) this.checkDataRowPosition(bomPreview, headerSelection, dataRow); } /** * Checks if Data Row value is valid compared to the Header Row value. * If Data Row is set to a invalid value it gets changed to the nearest valid value. */ checkDataRowPosition( bomPreview: BomPreviewModel, headerSelection: boolean, dataRow: FormControl ): void { if (bomPreview.dataRow <= bomPreview.headerRow) this.onDataRowChange( bomPreview.headerRow + 1, bomPreview, headerSelection, dataRow ); } /** * Cancels current mapping and redirects back to BOM management page. */ cancel(): void { this._analyticsService.googleTrackEvent(AnalyticsEvent.CLICK, { category: 'BOM', action: 'mapping', label: 'cancel' }); this._router.navigate(['/bom/']); } }