import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Input, NgZone, OnChanges, OnDestroy, OnInit, Output, QueryList, SimpleChanges, ViewChild, ViewChildren, ViewContainerRef } from '@angular/core'; import { WptSort } from '@arrow/warpaint/sort'; import { Row } from './ORM/types/custom/row.interface'; import { merge, Observable, Subject } from 'rxjs'; import { AbstractControl, FormControl } from '@angular/forms'; import { AnalyticsEvent, AnalyticsService, ApiSocketListenAction, AppSessionService, BomColumnType, BomCustomColum, BomItemService, BomListColumn, BomListDataSource, BomQuantity, BomService, ListItemModel, RowColumnsResponse, RowBomQuantityWithAutoRoundData, SocketsService, UserService2, Utils, PaginationParamsModel } from '@arrow/bom/core'; import { ConfigService } from '@arrow/bom/config'; import { take, takeUntil } from 'rxjs/operators'; import { animate, state, style, transition, trigger } from '@angular/animations'; enum BomQtyAction { Invalid = 0, Add = 1, NewActive = 2, Remove = 3 } const mobileResolutionWidth: number = 700; @Component({ selector: 'bom-list', animations: [ trigger('openClose', [ state( 'open', style({ //width: 'initial' }) ), state( 'closed', style({ width: '0', padding: 0, opacity: 0 }) ), transition('open => closed', [animate('0.5s')]), transition('closed => open', [animate('0.5s')]) ]) ], templateUrl: './list.component.html', styleUrls: ['./list.component.scss'] }) export class ListComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy { private _sigKill$$: Subject = new Subject(); public rowsDataSource; list$: Observable; fixedColumnHeaders: string[]; quantityColumnHeaders: string[]; scrollableColumnHeaders: string[]; scrollableAndFixedColumnHeadersCombined: string[]; customColumnsWithData: BomCustomColum[]; translatedHeaders:any = {}; // Default is true since this message will display while a BOM with parts is loading hasRows: boolean = true; appLoading: boolean = false; oneRowLoaded: boolean = true; altPricesCount: number = 0; controls: Map = new Map(); waitingOnQuoteResponse: boolean; showSelectOptions: boolean = false; autoCloseTimer: any; ECList: Array; isIndeterminate: boolean = false; addQtyVisible: boolean = false; siteTarget: string = ''; customColumns: any[] = []; public multiplierValue: number = null; public displayRA: boolean = true; public displayLA: boolean; public setActiveLoader: boolean; public removeQtyLoader: boolean; public newQtyLoader: boolean; public inactivateTariffLinkOnCrosses: boolean = false; public inactivateTariffIconOnCrosses: boolean = false; public _listData: BomListDataSource = { parts: 0, selection: { rowIds: [], selectionInverted: false }, columns: [], scrollableColumns: [], customColumns: [], rows: [] }; public multipleQtyColumns: BomQuantity[]; public eBomQtyAction = BomQtyAction; public isLowResolution = false; below320PxWidth = false; @ViewChild('scrollWrapper') scrollWrapper: ElementRef; @ViewChild('variableColTable', { read: ElementRef }) scrollTable: ElementRef; @ViewChildren(WptSort) _sortList: QueryList; @ViewChild('BomFormEl') bomFormEl: ElementRef; @ViewChild('selectOptions', { read: ViewContainerRef }) selectOptionsEl: ViewContainerRef; @ViewChild('btnSelectOptions') btnSelectOptionsEl: ElementRef; // @ViewChild('listWrapper') listWrapper: any; // @ViewChild('rowsWrapper') rowsWrapper: any; // @ViewChild('variableHeader', { read: ElementRef }) varHeader: ElementRef; // @ViewChildren(WptRow, { read: ElementRef }) rowsList: QueryList; @Input() listData: Partial; @Input() tariffsHref: string; @Input() currency: string; @Input() permissions: any; @Input() isAlternateModal?: boolean; @Input() collapseColumns?: boolean; @Input() loading: boolean; @Input() paginationParams: PaginationParamsModel; @Output() updateQuantity: EventEmitter = new EventEmitter(); @Output() buyPart: EventEmitter = new EventEmitter(); @Output() showBuyingOptions: EventEmitter = new EventEmitter(); @Output() showAlternatives: EventEmitter = new EventEmitter(); @Output() rowSelection: EventEmitter = new EventEmitter(); @Output() rowDelete: EventEmitter = new EventEmitter(); @Output() rowUndoDelete: EventEmitter = new EventEmitter(); @Output() sort: EventEmitter = new EventEmitter(); @Output() partNumber: EventEmitter = new EventEmitter(); @Output() selection: EventEmitter = new EventEmitter(); @HostListener('document:click', ['$event']) clickedOutside($event: any) { // If select options are open, check if this is an "outside" click that should close the element if (this._isOutsideSelectOptions($event)) { this.toggleSelectOptions(false); } } constructor( public bomItemService: BomItemService, private _config: ConfigService, private _appSessionService: AppSessionService, private _userService2: UserService2, private _analyticsService: AnalyticsService, private _socketService: SocketsService, private _zone: NgZone, private _utils: Utils, private _bomService: BomService, private _changeDetectorRef: ChangeDetectorRef ) { this.isLowResolution = window.outerWidth < mobileResolutionWidth; this.below320PxWidth = window.outerWidth <= 320; } ngOnInit() { this.siteTarget = this._config.getTarget(); this.bomItemService.resetSelection(); if (this._config.getTarget() === 'myarrow') { this._userService2 .getUserEndCustomers() .pipe(take(1)) .subscribe(response => { this.ECList = response; }); } this._socketService .observeAction(ApiSocketListenAction.ROW_COLUMNS) .pipe(takeUntil(this._sigKill$$)) .subscribe((data: any) => { this.onCustomColumnDataUpdate(data); }); this._bomService .getBomQuantitiesInformation() .pipe(take(1)) .subscribe(data => this._zone.run(() => { this.updateQuantityColumnsData(data) }) ); } ngAfterViewInit() { this.handleSortingChange(); } ngOnDestroy() { this._sigKill$$.next(null); } ngOnChanges(changes: SimpleChanges): void { let top; if (this.scrollTable && this.scrollTable.nativeElement.firstElementChild) { top = this.scrollTable.nativeElement.firstElementChild.offsetTop; } if (!this.isAlternateModal) { this.scrollWrapper && this.scrollWrapper.nativeElement.children[1] ? (this.scrollWrapper.nativeElement.children[1].style.top = this._config.getTarget() === 'myarrow' ? `${top + 10}px` : `${top + 15}px`) : ''; this.scrollWrapper && this.scrollWrapper.nativeElement.children[0] ? (this.scrollWrapper.nativeElement.children[0].style.top = this._config.getTarget() === 'myarrow' ? `${top + 10}px` : `${top + 15}px`) : ''; } if (changes.listData && changes.listData.currentValue) { this._listData = { ...this._listData, ...this.listData }; this._listData.scrollableColumns.sort((a, b) => (a.position > b.position) ? 1 : -1); this.multipleQtyColumns = this._listData.scrollableColumns .filter(col => col.type === BomColumnType.QUANTITY) as Array; this.scrollableColumnHeaders = this._listData.scrollableColumns .map(col => { col.name = this._utils.translateByKeyWithFallback(col.nameId, col.name); let translationKey = col.nameId || col.name; // To cover dynamically added columns like Quantities with no nameId this.translatedHeaders[translationKey] = col.name; return col.type == BomColumnType.QUANTITY ? col.key : col.key.toLowerCase(); }); this.customColumnsWithData = this._listData.scrollableColumns .filter(x => { const fixedColumnIds = BomListColumn.getFixedColumnIds(); if ( fixedColumnIds.indexOf(+x.id) !== -1 || x.type == BomColumnType.CUSTOM ) { return x; } }) .map(column => { return { mapId: +column.id, name: column.name, key: column.key, nameId : column.nameId }; }); this.fixedColumnHeaders = this._listData.columns .sort(x => x.position) .map(col => { col.name = this._utils.translateByKeyWithFallback(col.nameId, col.name); let translationKey = col.nameId || col.name; // To cover dynamically added columns like Quantities with no nameId this.translatedHeaders[translationKey] = col.name; return col.key.toLowerCase() }); if (!this.isAlternateModal) { this.fixedColumnHeaders.splice(0, 0, 'select'); } // Used for a combined single scrollable table displayed for lower screen resolutions this.scrollableAndFixedColumnHeadersCombined = this.fixedColumnHeaders.concat(this.scrollableColumnHeaders); this.isIndeterminate = this.bomItemService.isPartialSelection( this._listData.parts ); if (this.listData.rows.some(this.checkRowLoaded) && this.oneRowLoaded) { this.oneRowLoaded = false; this._analyticsService.googleTrack( AnalyticsEvent.PRODUCT_LIST_IMPRESSION, this.listData.rows ); } if (!this.isAlternateModal) { const el = this.scrollTable.nativeElement; const childEl = el.children[0]; const elValue = el.getBoundingClientRect().width; const childElValue = childEl.getBoundingClientRect().width; if (childElValue > elValue) { this.scrollWrapper.nativeElement.classList.add('show-right'); this.displayRA = true; } } } this._bomService .getBomQuantitiesInformation() .pipe(take(1)) .subscribe(data => this._zone.run(() => { this.updateQuantityColumnsData(data) }) ); } @HostListener('window:resize', ['$event']) onResize(event?) { var oldLowResolution = this.isLowResolution; this.isLowResolution = window.outerWidth < mobileResolutionWidth; this.below320PxWidth = window.outerWidth <= 320; if (oldLowResolution !== this.isLowResolution) { // Ensure QueryList of WptSort directives is updated for a new wptTable displayed this._changeDetectorRef.detectChanges(); // Ensure subscriptions to sortChange events for updated WptSort directives this.handleSortingChange(); } } private handleSortingChange(): void { merge(...this._sortList.map(s => s.sortChange)) .pipe(takeUntil(this._sigKill$$)) .subscribe((sortData: any) => { this.sort.emit(sortData); }); } private onCustomColumnDataUpdate(ccData: RowColumnsResponse[]) { if (ccData) { ccData.map(apiResponse => { let rowToUpdate = this._listData.rows.find( row => row.id == apiResponse._id ); if (rowToUpdate && apiResponse.customerFields) { rowToUpdate.item.customColumns.map(rowCustomColumn => { let customColumn = apiResponse.customerFields.find( field => field.mapId == rowCustomColumn.mapId ); if (customColumn && rowCustomColumn.value !== customColumn.value) { rowCustomColumn.value = customColumn.value; } }); } }); } } private updateQuantityColumnsData(bomQuantitiesResponse: any) { if (bomQuantitiesResponse) { const quantities: Array = bomQuantitiesResponse as Array; quantities.map(qty => { let qtyToUpdate = this.multipleQtyColumns.find( x => x.key === qty.id ) as BomQuantity; if (!qtyToUpdate) { //we will assume a new quantity was added qtyToUpdate = qty; qtyToUpdate.key = qty.id; qtyToUpdate.name = this._utils.translateByKeyWithFallback(qty.name.toLocaleLowerCase().replace(' ', '-'), qty.name); qtyToUpdate.type = BomColumnType.QUANTITY; const insertAt = this.getNextQuantityArrayPosition(qtyToUpdate); const insertedColumn = this._listData.scrollableColumns.find(x=>x.key == qtyToUpdate.key); if(!insertedColumn) { this._listData.scrollableColumns.splice(insertAt, 0, qtyToUpdate); } } else { (qtyToUpdate.name = qty.name), (qtyToUpdate.isActive = qty.isActive), (qtyToUpdate.isDefault = qty.isDefault), (qtyToUpdate.position = qty.position); qtyToUpdate.nameId = qty.nameId; qtyToUpdate.openOptions = false; } if (qtyToUpdate.isActive) { this._bomService.onBomActiveQtyColumnChange(qtyToUpdate); } }); this.removeQtyLoader = false; this.newQtyLoader = false; this.setActiveLoader = false; } } private getNextQuantityArrayPosition(bomQuantity: BomQuantity): number{ if(!bomQuantity || bomQuantity.position < 1){ return 0; } let nextIndex = 0; const previousQtyIndex = bomQuantity.position - 1; this._listData.scrollableColumns.forEach(function(value: BomListColumn, index: number){ if(value.type && value.type == BomColumnType.QUANTITY && value.position == previousQtyIndex){ nextIndex = index + 1; } }) return nextIndex; }; public getRowQuantity(quantityId: string, row: Row): AbstractControl { const rowItem: ListItemModel = row.item; let quantityRequested = 1; if (rowItem && rowItem.quantities) { const quantityColumn: RowBomQuantityWithAutoRoundData = rowItem.quantities.find( x => x.id == quantityId ); quantityRequested = quantityColumn ? quantityColumn.displayQuantity : quantityRequested; } row.control.addControl(quantityId, new FormControl(quantityRequested)); return row.control.controls[quantityId]; } /** * Helps determine whether the passed in event.target is one of the "select options" elements * @param */ private _isOutsideSelectOptions($event: any): boolean { // Check if click is on the "select options" element if ( this.selectOptionsEl && ($event.target === this.selectOptionsEl.element.nativeElement || $event.target.parentNode === this.selectOptionsEl.element.nativeElement) ) { return false; } // Check if click is on the drop down button that toggles the "select options" element // we ignore this click since there is another handler that handles showing/hiding "select options" return !( this.btnSelectOptionsEl && ($event.target === this.btnSelectOptionsEl.nativeElement || $event.target.parentNode === this.btnSelectOptionsEl.nativeElement) ); } checkRowLoaded(row: any) { return row.loaded; } toggleSelectOptions(showFlag?: boolean, selectStatus?: boolean) { if (selectStatus !== undefined) this.onSelect(selectStatus); if (typeof showFlag === 'undefined') { this.showSelectOptions = !this.showSelectOptions; } else { this.showSelectOptions = showFlag; } if (this.showSelectOptions) { // This is relative to the entire document const topBtn = this.btnSelectOptionsEl.nativeElement.getBoundingClientRect() .top; // This is the top of the "BOM tool" relative to the document const topBomForm = this.bomFormEl.nativeElement.getBoundingClientRect() .top; const posInDoc = topBtn - topBomForm + 7; this.selectOptionsEl.element.nativeElement.style.top = posInDoc + 'px'; } } toggleSelectOptionsButtonClicked() { // This "proxy method" will prevent "select options" from automatically // popping up if the user has already seen them before checking any boxes this._appSessionService.autoShowSelectOptions = false; this.toggleSelectOptions(); } /** * Format the PDP link dependeing on site target * @param item */ getPdpUrl(item: any): string { if ( item && item.partName && !item.isLoading && this.siteTarget === 'myarrow' && (item.rankedSourceId || item.sourcePartId) ) { return `${this._config.arrowUrl}/products/${item.rankedSourceId || item.sourcePartId}`; } else if (item && item.partName && !item.isLoading && item.detailLink) { return `${this._config.arrowUrl}/en/products/${item.detailLink}`; } return ''; } // called in bom-custom-column html mapAndGetCustomColumn( id: number, rowCustomColumns: BomCustomColum[] ): BomCustomColum { if (!id && !rowCustomColumns) return null; let customColumnData = this.customColumnsWithData.find(x => x.mapId == id); const rowCustomColumn = rowCustomColumns.find( x => x.mapId == customColumnData.mapId ); if (rowCustomColumn) { customColumnData.value = rowCustomColumn.value; } return customColumnData; } onUpdateQuantity(row: Row, qtyColumnId: string, quantity): void { const inputControl: AbstractControl = row.control.controls[qtyColumnId]; if (inputControl) { const originalQtyColumnData = row.item.getQuantityColumn(qtyColumnId); const originalQty = originalQtyColumnData ? originalQtyColumnData.displayQuantity : 1; if (isNaN(inputControl.value) || inputControl.value < 1 || originalQty === inputControl.value) { inputControl.setValue(originalQty); } } this.updateQuantity.emit({ row, quantity }); } onShowBuyingOptions(row: Row): void { this.showBuyingOptions.emit(row); } onShowAlternatives(row: Row): void { this.showAlternatives.emit(row); } onBuyPart(row): void { this.buyPart.emit(row); } onPartNumberChange(partNumber: string, rowId: string): void { this.partNumber.emit({ partNumber, rowId }); } onBuyingOptions(row: Row): void { this.showBuyingOptions.emit(row); } onRowDelete(item: ListItemModel): void { this.rowDelete.emit(item); } onUndoDelete(row): void { this.rowUndoDelete.emit(row); } onSelect(status: boolean, row?: Row, $event?: any): void { $event?.target.click(); // auto-check without pressing space or clicking this.selection.emit({ rowId: row ? row.id : '', status }); } onTableScroll() { if(!this.isAlternateModal) { let scrollWidth = this.scrollWrapper.nativeElement.scrollWidth; let divWidth = this.scrollWrapper.nativeElement.offsetWidth; let scrollPosition = this.scrollWrapper.nativeElement.scrollLeft; scrollPosition === 0 ? this.displayLA = false : this.displayLA = true; scrollWidth - divWidth === scrollPosition ? this.displayRA = false : this.displayRA = true; } else if(this.isAlternateModal) { // Meet accessibility requirements - disable interactable elements for assistive technologies if they are not visible to the users this.controlA11yForTableRightScrollOnCrosses(this.scrollWrapper.nativeElement.scrollLeft); } } scrollLeft(): void { const pxtoMove = this.scrollWrapper.nativeElement.scrollWidth - this.scrollWrapper.nativeElement.offsetWidth; this.scrollWrapper.nativeElement.scrollBy(-pxtoMove, 0); } scrollRigth(): void { const pxtoMove = this.scrollWrapper.nativeElement.scrollWidth - this.scrollWrapper.nativeElement.offsetWidth; this.scrollWrapper.nativeElement.scrollBy(pxtoMove, 0); } private controlA11yForTableRightScrollOnCrosses(currentScrollPosition: number): void { if (!this.below320PxWidth) { // Screen resolution is above 320px - assuming non-mobile normal resolution if (currentScrollPosition >= 60 && currentScrollPosition < 405) { this.inactivateTariffLinkOnCrosses = true; this. inactivateTariffIconOnCrosses = false; } else if (currentScrollPosition >= 405) { this.inactivateTariffLinkOnCrosses = true; this. inactivateTariffIconOnCrosses = true; } else { this.inactivateTariffLinkOnCrosses = false; this. inactivateTariffIconOnCrosses = false; } } else { // Screen width is 320px - mobile phone resolution if (currentScrollPosition >= 380) { this.inactivateTariffLinkOnCrosses = true; this. inactivateTariffIconOnCrosses = false; } else { this.inactivateTariffLinkOnCrosses = false; this. inactivateTariffIconOnCrosses = false; } } } toggleOptionList(column: BomQuantity) { this.multipleQtyColumns.forEach(qty => { if (qty.key != column.key) { qty.openOptions = false; } }); return (column.openOptions = !column.openOptions); } handleQtyActions(qtyAction: number, columnId?: string , colNumber?: string, newColumn?: number,multiplierValue?: number): void { qtyAction === BomQtyAction.NewActive ? this.setActiveLoader = true : this.setActiveLoader = false if (qtyAction === BomQtyAction.Remove) { this.removeQtyLoader = true; const removeIndex = this._listData.scrollableColumns.findIndex(item => item.key === columnId); this._listData.scrollableColumns.splice(removeIndex,1); } const multiplier = this.multiplierValue; if(qtyAction === BomQtyAction.Add){ this.newQtyLoader = true; this.multiplierValue = null; } this._bomService.updateQtyCol(this.paginationParams, qtyAction, columnId, multiplier ?? -1); //Analytics for all Qty Col actions let analyticAction; let analyticLabel; switch (qtyAction){ case 1: analyticAction = 'Multiplier'; analyticLabel = `Quantity ${newColumn} Column Added` break; case 2: analyticAction = 'Active'; analyticLabel = `Set Quantity ${colNumber} column Active` break; case 3: analyticAction = 'Remove'; analyticLabel = `Removed Quantity ${colNumber} Column ` break; } this._analyticsService.QtyTrackEvent(AnalyticsEvent.CLICK, { category: 'BOM', action: analyticAction, label: analyticLabel, CM10: qtyAction === 1 ? multiplierValue : 0, CM15: qtyAction === 1 ? multiplierValue : 0 , }); } public trackFn (index: number, item: BomQuantity) { return item.key; } getTranslatedAlternatesRadioButtonAccessibleLabel(row: Row): string { return this._utils.translateByKeyWithFallback("part-number", "Part Number") + " " + (row.part.number || row.item.originalName || "unknown") + ". " + this._utils.translateByKeyWithFallback("manufacturer", "Manufacturer") + (row.item.manufacturer || "unknown"); } }