import { Component, OnInit, OnDestroy, NgZone, ElementRef, ViewChild } from '@angular/core'; import { Router, ActivatedRoute } from '@angular/router'; import { FormControl, FormGroup } from '@angular/forms'; import { FeatureFlagService } from '@arrow/bom/feature-flag'; import { WptDialog } from '@arrow/warpaint/dialog'; import { MessageBusService, ModalService, AnalyticsService, SocketsService, Utils, PermissionsService, setCookie, DialogService, BomService, BomItemService, BomModel, ListItemModel, Entity2textPipe, AppSessionService, UserService, BomListDataSource, BomMessage, AnalyticsEvent, ApiSocketEmitAction, ApiSocketListenAction, CartHandoverService, PaginationParamsModel } from '@arrow/bom/core'; import { ConfigService } from '@arrow/bom/config'; import { LoggerService } from '@arrow/bom/logger'; import { Subscription, Observable, combineLatest, BehaviorSubject, Subject } from 'rxjs'; import { filter, first, map, take, takeUntil } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; import { ListService, Row, BuyingOptionsComponent } from '@arrow/bom/list'; import { AlternativesModalComponent } from '@arrow/bom/alternatives-modal'; import { AlternativesType } from './common/enums'; import { Cart2Service } from '@arrow/bom/cart2'; import { BomColumnType } from '@arrow/bom/core'; const UNKNOWN_DATA_STATE = 0; const CACHE_DATA_STATE = 1; const REFRESH_DATA_STATE = 2; @Component({ templateUrl: 'view.component.html', styleUrls: ['view.component.scss'] }) export class ViewComponent implements OnInit, OnDestroy { // !Important // TODO: check effect of MyArrow having all ViewChilds as 'static' //@ViewChild('BomFormEl') public bomFormEl: ElementRef; @ViewChild('BomFormEl', { static: true }) public bomFormEl: ElementRef; public paginationParamsModel: PaginationParamsModel = new PaginationParamsModel(); public bom: BomModel = new BomModel(); public subscriptions: Subscription[] = []; public bomLoading = true; public isAnonymous: boolean; public partInfo$: Observable; // Supports coauthoring experience in the case that one user adds parts // The user who does the add will flip this flag to true for themselves public isPendingAddMulti = false; public isWaitingForFirstPartData = false; // Default is true since this message will display while a BOM with parts is loading public appLoading = false; public altPricesCount = 0; public permissions: any; public loading = true; public controls: Map = new Map(); public autoCloseTimer: any; public isIndeterminate = false; public listData$: Observable>; public listConfig$$: BehaviorSubject>; public firstBomLoad = true; public loadTimestamp: Date; public killSig$$ = new Subject(); // Kill signal for observables public siteTarget = ''; //!important: Dependencies should be added as public to be inherited in Arrow.com ViewExtension component constructor( //!important: HooksService is used in MyArrow only - there is ViewExtension implementation in MyArrow // private hooks: HooksService, //!important: Used in Arrow.com only but keep here - in Arrow.com we are fully inheriting ViewComponent from bom-tool public cartHandoverService: CartHandoverService, public router: Router, public activatedRoute: ActivatedRoute, public bomService: BomService, public modalService: ModalService, public messageBus: MessageBusService, public analyticsService: AnalyticsService, public sockets: SocketsService, public zone: NgZone, public permissionsService: PermissionsService, public dialogService: DialogService, public listService: ListService, public loggerService: LoggerService, public appSessionService: AppSessionService, public wptDialog: WptDialog, public cart2: Cart2Service, public translate: TranslateService, public config: ConfigService, public bomItemService: BomItemService, public utils: Utils, public ffService: FeatureFlagService, public e2t: Entity2textPipe, public userService: UserService ) { if (ffService.maintenance) { this.router.navigate(['maintenance'], { relativeTo: this.activatedRoute }); } //!important: MyArrow specific - not needed here since there is ViewExtension implementation in MyArrow //this.translate.setDefaultLang('en-US'); } /** * On bomView init show loader (disabled later, when everything is loaded), initiate filter, get translations, * Subscribe to crucial socket actions (api errors & initial clientId, on which you reset bom and subscribe to everything else) */ // !Important: MyArrow overrides ngOnInit function. Arrow.com does not - completely inherits component from bom-tool public ngOnInit() { this.loadTimestamp = new Date(); this.siteTarget = this.config.getTarget(); if (this.config.isArrowcom() && this.config.userSelectedCurrency !== this.config.defaultCurrency) { this.cartHandoverService.get(); } else if (this.siteTarget === 'myarrow') { this.config.userSelectedCurrency = 'USD'; // just stubbed to allow testing locally // this.loggerService.info('Subscribing to getCurrencyObservable()'); // this.subscriptions.push( // this.hooks.getCurrencyObservable().subscribe(currency => { // if (currency === null) { // this.loggerService.info('"null" currency value received from "hooks.getCurrencyObservable()" | not setting userSelectedCurrency'); // return; // } // this.loggerService.info( // `bomHook currency received. currency: ${currency} | this.config.userSelectedCurrency: ${this.config.userSelectedCurrency}` // ); // if (this.config.userSelectedCurrency === null) { // this.loggerService.info(`Initial currency: ${currency}`); // this.config.userSelectedCurrency = currency; // } else if (this.config.userSelectedCurrency !== currency) { // this.loggerService.info('Currency changed after page load. Calling "getBomData()"'); // this.config.userSelectedCurrency = currency; // this.getBomData(REFRESH_DATA_STATE); // } // }) // ); } this.messageBus.to('ch:filters', { payload: true }); this.resetBomParameters(UNKNOWN_DATA_STATE); this.initializeSubscriptions(); this.initializeSocketsSubscriptions(); // DO NOT REMOVE! this event is used to update the bom quantities each time a quiantity is added/removed // right now it is only updated in the list.component object and not in the main bom object // for now this event is not needed but may be useful in the future if we need to listen to this changes in other components // TODO: the BE needs to make the object 'bomQuantities' have the same values as the elements with 'nameId' = 'quantity' in // 'bom.bomColumns' to avoid possible issues // this.sockets // .observeAction(ApiSocketListenAction.BOM_QUANTITIES) // .pipe(takeUntil(this.killSig$$)) // .subscribe((bomQuantities: any) => { // // remove quantities to update the bom object with the new ones // for (let i = this.bom.bomColumns.length-1; i >= 0; i--) { // if (this.bom.bomColumns[i].nameId?.startsWith('quantity')) { // this.bom.bomColumns.splice(i, 1); // } // } // this.bom.bomColumns.push(...bomQuantities); // }); // event to update the exportable columns displayed in the download modal, specially the quantities, each time one is added/deleted this.subscriptions.push( this.sockets .observeAction(ApiSocketListenAction.EXPORTABLE_COLUMNS) .pipe(takeUntil(this.killSig$$)) .subscribe((exportableColumns: any) => { if (exportableColumns.outputColumns) { this.bom.exportableColumns = exportableColumns.outputColumns; } }) ); } public ngOnDestroy(): void { // TODO: watch unsubscribe logic - it was added during view.component merge across projects. // !important this technic may be a duplication of killSig$$ usage this.subscriptions.forEach(subscription => { subscription.unsubscribe(); }); this.killSig$$.next(); this.killSig$$.complete(); this.loggerService.info('view.component | ngOnDestroy() triggered'); if (this.autoCloseTimer) clearTimeout(this.autoCloseTimer); } /** * Create an empty Bom Model, set it with parameters from the urls (id, currentPage) * If it's a valid bom id, get the bom data, if not, display a modal and redirect to homepage * Needs to validate id, coz if invalid id, apparently it doesn't even attempt a request on the BE */ public resetBomParameters(dataSate: number): void { this.bom = new BomModel(); this.activatedRoute.params.subscribe(params => { this.bomService.setDataUrl(params.id); this.updatePagination(params.page, params.size, +params.page); if (this.isValidBomId(params.id)) { this.bom.setId(params.id); this.getBomData(dataSate); } else { this.showMessage({ message: this.translate.instant('Incorrect BOM id') }); this.delayedRedirect('/bom'); } }); } /** * Create the subscriptions array, subscribe to all sorts of observables and push those to the * subscription array for 1 step unsubscribe later */ public initializeSubscriptions() { /** * @deprecated Since version 1.0 will be deleted. Use WptDialog instead */ // this.subscriptions.push( // this._modalService.noModalConfirmation$.subscribe(() => { // cookie.set('noConfirmation', '1', this.config.getDomain()); // }) // ); // !Important: re-enabled above during component merge: was enabled in both MyArrow and arrow.com this.subscriptions.push( this.modalService.noModalConfirmation$ .pipe(takeUntil(this.killSig$$)) .subscribe(() => { setCookie('noConfirmation', '1', this.config.getDomain()); }) ); this.subscriptions.push( this.messageBus .from('ch:loader') .subscribe((message: BomMessage) => { this.appLoading = message.payload; this.bomLoading = message.payload; }) ); this.subscriptions.push( this.userService.isReady() .pipe(first()) .subscribe(() => { this.isAnonymous = this.userService.isAnonymous; }) ); // Permissions this.subscriptions.push( this.permissionsService.getPermissions() .pipe(takeUntil(this.killSig$$)) .subscribe((permissions: any) => (this.permissions = permissions)) ); this.subscriptions.push( this.bomItemService.userChangedSelection$ .pipe(first()) .subscribe(data => { if (this.appSessionService.autoShowSelectOptions) { setTimeout(() => { // this.toggleSelectOptions(true); this.startAutoCloseHandler(); this.appSessionService.autoShowSelectOptions = false; }, 200); } }) ); } /** * Push sockets related subscriptions to the subscriptions array */ public initializeSocketsSubscriptions(): void { // On BOM deleted this.subscriptions.push(this.sockets .observeAction(ApiSocketEmitAction.BOM_DELETED) .pipe(take(1)) .subscribe((data: any) => { this.zone.run(() => { // show the modal that the BOM has been deleted this.subscriptions.push( this.dialogService .alert( this.translate.instant('Sorry, this BOM has been deleted'), this.translate.instant( 'This BOM has been deleted by the owner and will no longer be accessible from your account. You will be returned to the homepage. ' ), { buttons: { confirm: this.translate.instant('PROCEED TO HOMEPAGE') }, style: 'button-full-width' } ) .subscribe(() => { // !Important // bom-tool had 'relativeTo' set. myarrow/arrowcom did not // this.router.navigate(['/bom'], { relativeTo: this.activatedRoute }); this.router.navigate(['/bom']); }) ); }) }) ); this.subscriptions.push( this.sockets.observeAction(ApiSocketListenAction.BOM_TOTALS) .pipe(takeUntil(this.killSig$$)) .subscribe((data: any) => { this.zone.run(() => { this.onBomLoadingComplete(); }); }) ); // On bomStub this.subscriptions.push( this.sockets.observeAction(ApiSocketListenAction.BOM_STUB) .pipe(takeUntil(this.killSig$$)) .subscribe((bomStub: any) => { this.zone.run(() => { const response = { payload: bomStub }; this.bom = new BomModel(response); this.initListObservables(); }); }) ); // On row info only this.subscriptions.push( this.sockets.observeAction(ApiSocketListenAction.PART_ROW_INFORMATION) .pipe(takeUntil(this.killSig$$)) .subscribe((rows: any) => { this.zone.run(() => { this.updateRowsInfo(rows); this.updateRows(rows); }); }) ); // On row pricing only this.subscriptions.push( this.sockets.observeAction(ApiSocketListenAction.PART_ROW_PRICING) .pipe(takeUntil(this.killSig$$)) .subscribe((rows: any) => { this.zone.run(() => { this.updateRowsPricing(rows); }); }) ); // On row alts this.subscriptions.push( this.sockets.observeAction(ApiSocketListenAction.ALTERNATE_PART) .pipe(takeUntil(this.killSig$$)) .subscribe((alts: any) => { this.zone.run(() => { this.updateRowAlts(alts); }); }) ); // On row completed loading this.subscriptions.push( this.sockets.observeAction(ApiSocketListenAction.PART_ROW_COMPLETE) .pipe(takeUntil(this.killSig$$)) .subscribe((data: any) => { this.zone.run(() => { this.setItemsFullyLoaded(data); }); }) ); // On row added this.subscriptions.push( this.sockets.observeAction(ApiSocketListenAction.ROW_ADDED) .pipe(takeUntil(this.killSig$$)) .subscribe((data: any) => { this.zone.run(() => { if (data && data.length > 0) this.appendNewRows(data); }); }) ); // On page counts received this.subscriptions.push( this.sockets.observeAction(ApiSocketListenAction.PAGE_COUNTS) .pipe(takeUntil(this.killSig$$)) .subscribe((data: any) => { this.zone.run(() => { this.updatePageCounts(data); }); }) ); this.subscriptions.push( this.sockets.observeAction(ApiSocketListenAction.ROW_DELETED) .pipe(takeUntil(this.killSig$$)) .subscribe((rowId: any) => { this.zone.run(() => { this.handleDeletedRow(rowId); }); }) ); this.subscriptions.push( this.sockets.observeAction(ApiSocketListenAction.ROW_RESTORED) .pipe(takeUntil(this.killSig$$)) .subscribe(data => { this.zone.run(() => { this.handleRestoredRow(data); }); }) ); this.subscriptions.push( this.sockets.observeApiErrors() .pipe(takeUntil(this.killSig$$)) .subscribe(apiError => { this.zone.run(() => { this.messageBus .to('ch:loader', { payload: false }) .to('ch:cart', { payload: false }) .to('ch:filters', { payload: false }); }); }) ); } public initListObservables() { const bomColumns = this.bom && this.bom.bomColumns ? this.bom.bomColumns : []; this.listConfig$$ = new BehaviorSubject>({ columns: bomColumns.filter(x => x.type == BomColumnType.STICKY), config: { stickyHeader: true, stickyTopPx: 0 }, customColumns: [], scrollableColumns: bomColumns.filter( x => x.type != BomColumnType.STICKY || BomColumnType.INVALID ), selection: this.bomItemService.getSelection(), }); /** * List component code */ this.listData$ = combineLatest([ this.listConfig$$.asObservable(), this.listService.getObservable() ]).pipe( filter(() => !!this.bom), map(([config, list]) => { if (this.bom.customColumns && this.bom.customColumns.length > 0) { config.customColumns = [...this.bom.customColumns]; } return { ...config, parts: this.bom.parts, rows: this.mergeData(list, this.bom.rows), selection: this.bomItemService.getSelection(), }; }) ); } // <--- Refactor ---> public onSort(sortData) { this.loading = true; this.listService.resetRowsReducer(); const fieldName = this.getColumnByName(sortData.active); this.paginationParamsModel.sorting = { fieldName, sortString: sortData.direction }; this.getBomData(CACHE_DATA_STATE); } // <--- Refactor ---> public onSelection(selection: { rowId: string; status: boolean }): void { if (!selection.rowId) this.bomItemService.toggleRowsSelection([], selection.status); else this.bomItemService.updateSelection( selection.rowId, selection.status, this.bom.parts ); this.listConfig$$.next({ selection: this.bomItemService.getSelection() }); } public onMultiplierChanged(): void { // Set all rows to loading, they will automatically update as the processing happens if (this.bom.rows && this.bom.rows.length > 0) { this.bom.rows.forEach(r => (r.isLoading = true)); } this.messageBus.to('ch:loader', { payload: true }); } /** * Update the partNumber * @param partObj The object with partNumber and rowId */ public onPartNumberChange(partObj): void { if (!partObj.partNumber || typeof partObj.partNumber !== 'string') return; this.messageBus .to('ch:loader', { payload: true }) .to('ch:filters', { payload: true }); this.bomItemService.setItemLoading(partObj.rowId, true); this.sockets.emitAction(ApiSocketEmitAction.UPDATE_ROW, { bomId: this.bom.id, rowId: partObj.rowId, pn: partObj.partNumber, mfr: null, qty: 0, pid: -1, pageNum: this.paginationParamsModel.currentPage, perPage: this.paginationParamsModel.itemsPerPage, filter: this.paginationParamsModel.filter, sorting: this.paginationParamsModel.sorting }); } /** * Navigates to the bom tool home page. */ public goHome(): void { this.router.navigate(['../../../'], { relativeTo: this.activatedRoute }); } /** * Hide any modals and redirect to a url for a more natural user experience (enough to read the modal message and then show a * loader for a moment before the redirect) * @param url - route to redirect to * @deprecated Since version 1.0 will be deleted. Use WptDialog instead */ public delayedRedirect(url: string): void { setTimeout(() => { this.modalService.hideMessages(); // If not clicked redirect or log in or whatever, redirect to the home page with a delay so that it's not jumpy setTimeout(() => { this.router.navigate([url]); }, 1000); }, 2500); } /** * On sockets partRowComplete identify relevant loaded row models and set them to loaded * @param rowIds - an array of rowIds that have fully loaded (1 or more) */ public setItemsFullyLoaded(rowIds: Array): void { if (!this.bom.rows) return; if (rowIds) { rowIds.forEach(rowId => { const row = this.bom.rows.find(bomRow => bomRow.rowId === rowId); if (row && row.hasPriceInfo) { row.setItemModelLoading(false); row.setPriceItemModelLoading(false); row.tracked = false; } }); } } /** * For each incoming row data, update the item model in the bom model * @param rows - an array of row data from the BE */ public updateRows(rows: Array = []) { let pageToRedirect: number; rows.forEach((row: any) => { if (this.isWaitingForFirstPartData) { this.isWaitingForFirstPartData = false; row.scrollToOnInit = true; } // !Important: eslint rule suppression arrived from MyArrow // eslint-disable-next-line no-underscore-dangle pageToRedirect = this.bom.updateRow(row._id, row); }); if (pageToRedirect) { this.router.navigate(['../../../view', this.bom.id, pageToRedirect], { relativeTo: this.activatedRoute }); } } public appendNewRows(rowIds: string[]): void { const numNewRows = rowIds.length; if (numNewRows === 0) return; const thisUserInitiatedAdd: boolean = this.isPendingAddMulti; const emptyRowSlots = this.paginationParamsModel.itemsPerPage - this.bom.rows.length; if (emptyRowSlots > 0) { rowIds = rowIds.slice(0, emptyRowSlots); this.messageBus.to('ch:loader', { payload: true }); if (thisUserInitiatedAdd) this.isWaitingForFirstPartData = true; } else { if (thisUserInitiatedAdd) { // Target page is the page where the FIRST added part will appear const totalPlusOne = this.bom.parts + 1; const targetPage = Math.ceil(totalPlusOne / this.paginationParamsModel.itemsPerPage); this.router.navigate(['../../../view', this.bom.id, targetPage], { relativeTo: this.activatedRoute }); } else { this.messageBus.to('ch:loader', { payload: true }); this.sockets.emitAction(ApiSocketEmitAction.GET_PAGE_COUNTS, { bomId: this.bom.id }); } } } /** * * @param rowsInfo */ public updateRowsInfo(rowsInfo: Array = []) { rowsInfo.forEach((info: any) => { // !Important eslint rule suppression arrived from MyArrow // eslint-disable-next-line no-underscore-dangle this.bom.updateRowInfo(info._id, info); // !Important eslint rule suppression arrived from MyArrow // eslint-disable-next-line no-underscore-dangle this.bomItemService.notifyRowInfoUpdated(info._id); }); } /** * * @param rowsPricing */ public updateRowsPricing(rowsPricing: Array = []) { rowsPricing.forEach((pricing: any) => { // !Important eslint rule suppression arrived from MyArrow // eslint-disable-next-line no-underscore-dangle this.bom.updateRowPricing(pricing._id, pricing); // !Important eslint rule suppression arrived from MyArrow // eslint-disable-next-line no-underscore-dangle this.bomItemService.notifyRowPricingUpdated(pricing._id); }); } /** * * @param alts */ public updateRowAlts(rowsAlts: Array = []) { // !Important eslint rule suppression arrived from MyArrow // eslint-disable-next-line no-underscore-dangle rowsAlts.forEach((alts: any) => this.bom.updateRowAlts(alts._id, alts.alternates)); } /** * On WS action result for row deletion, update bom model setting isDeleted to true (still displaying row) * update bom totals, filters etc.; Set item loading to false * @param rowId - deleted row id * @param isSucess - deletion success true/false */ public handleDeletedRow(rowId: string) { this.bom.amendRow(rowId, { isDeleted: true, hiddenAlts: true, index: -1, selected: false // Ensure row is deselected }); this.bomItemService.removeFromSelections(rowId); this.bomItemService.setItemLoading(rowId, false); } /** * When a row is "un-deleted" run some logic to update indexes for the whole page * Also update the selectionArray */ public handleRestoredRow(data: any) { this.bom.amendRow(data.rowId, { isDeleted: false }); this.bomItemService.newItemAdded(); } public updatePagination(page: number, perPage: number, currentPage: number) { this.bomService.setPageNumber(page || 1); this.bomService.setPageSize(perPage || 25); this.paginationParamsModel.itemsPerPage = perPage; this.paginationParamsModel.currentPage = currentPage || 1; } /** * Basic BOM id validation, so that it gets to perform a db request on the BE * @param id - bom id * @returns boolean valid id or not */ public isValidBomId(id: string): boolean { const pattern = /^[a-f\d]{24}/g; return !!id.match(pattern); } /** * Set bom & cart to not fully loaded, websocket request bom data */ public getBomData(dataSate: number) { if (dataSate === UNKNOWN_DATA_STATE) { dataSate = this.needToRefreshData() ? REFRESH_DATA_STATE : CACHE_DATA_STATE; if (this.firstBomLoad) { this.firstBomLoad = false; } } this.messageBus .to('ch:loader', { payload: true }) .to('ch:cart', { payload: true }) .to('ch:filters', { payload: true }); console.log(`STARTED LOADING BOM AT: ${performance.now()}`) this.permissionsService.loadPermissions(this.bom.id); this.sockets.emitAction(ApiSocketEmitAction.GET_PAGE, { bomId: this.bom.id, pageNum: this.paginationParamsModel.currentPage, perPage: this.paginationParamsModel.itemsPerPage, filter: this.paginationParamsModel.filter, sorting: this.paginationParamsModel.sorting, dataState: dataSate }) .subscribe(null, () => { this.messageBus .to('ch:loader', { payload: false }) .to('ch:cart', { payload: false }) .to('ch:filters', { payload: false }); }); } public updatePageCounts(data: any) { if (data && data && this.bom.paging) { // To trigger change detection we need to update the whole 'this.bom.paging' object // but we need to update only a single value, so we "reset" the object so it looks new const updatedPagination = JSON.parse(JSON.stringify(this.bom.paging)); updatedPagination.totalPages = data.totalPages; this.bom.paging = updatedPagination; // TODO // may need to have some logic to cover case where user is on page 2 // Parts get deleted on page 1 and cause the last page to "no longer exist" // Triggering a "single page BOM", which makes pagination controls disappear... // causing the user to be stuck on page 2 with a single part, and not able // to navigate to page 1 } } public updatePage(page: any) { this.bom.clearRows(); this.router.navigate( ['../../..', this.bom.id, page.pageIndex + 1, page.pageSize], { relativeTo: this.activatedRoute } ); } /** * Show modal for Multiple Buying Options */ public buyingOptionsModal(row: Row): void { this.wptDialog.open(BuyingOptionsComponent, { // Ensure modal window content is scrollable on smaller screen resolutions (i.e. 320px) panelClass: 'bom-buying-options-cdk-overlay-pane', // See _normalize.scss width: '860px', data: { row, paginationParams: this.paginationParamsModel } }); } public openAlternativesModal(row: Row): void { this.analyticsService.googleTrackEvent(AnalyticsEvent.CLICK, { category: 'BOM', action: 'Alt/Cross Toggle', label: row.item.hiddenAlts ? 'Open' : 'Close' }); var alternateParams = this.getAlternatesPricing(row); this.wptDialog.open(AlternativesModalComponent, { // Ensure modal window content is scrollable on smaller screen resolutions (i.e. 320px) panelClass: 'bom-alternates-cdk-overlay-pane', // See _normalize.scss data: { adjustHeight: (data: any) => { }, alternativeType: row.part.id > 0 ? AlternativesType.crosses : AlternativesType.alternatives, fixedColumnHeaders: [], isQuantityValid: this.isQuantityValid(row.item), previousPart: row.item.previousPart, rowData: row, bomId: this.bom.id, alternateParams, selection: (data: any) => { this.saveAlternate.call(this, data); }, trackDataSheet: (id: number) => { this.trackDataSheet.call(this, id); }, }, maxWidth: '860px' }); } /** * On SELECT send updateRow action to the socket for the item to be swapped for the alternate * Sets loaders until it gets the data back * @param data - Current and new list items */ public saveAlternate(data: { current: ListItemModel; next: ListItemModel; isPrevious?: boolean }) { const newPart: ListItemModel = data.next; const currentPart: ListItemModel = data.current; const isPrevious = data.isPrevious; const proposedPartName = newPart.partName; const proposedManufacturer = newPart.manufacturer; const proposedPartId = newPart.partId; currentPart.isLoading = true; if(isPrevious) { this.analyticsService.googleTrackEvent(AnalyticsEvent.CLICK, { action: 'Select Previous Part from Crosses', category: 'BOM', label: newPart.partName }); } else { this.analyticsService.googleTrackEvent(AnalyticsEvent.CLICK, { action: 'Alt/Cross Save', category: 'BOM', label: currentPart.partName }); } // Collapse and clear alts this.bomItemService.toggleAlternates(currentPart.rowId, true); this.bomItemService.setItemLoading(currentPart.rowId, true); // Sets current item row to loading (visible loader) this.messageBus .to('ch:loader', { payload: true }) .to('ch:cart', { payload: true }) // !Important: Came from Arrow.com .to('ch:filters', { payload: true }); newPart.resetAllProperties(); currentPart.partName = proposedPartName; //currentPart.originalName = proposedPartName; // Send only the necessary info to change the partID (a qty of 0 will keep qty unchanged) this.sockets.emitAction(ApiSocketEmitAction.UPDATE_ROW, { bomId: this.bom.id, rowId: currentPart.rowId, pn: proposedPartName, mfr: proposedManufacturer, qty: 0, pid: proposedPartId, pageNum: this.paginationParamsModel.currentPage, perPage: this.paginationParamsModel.itemsPerPage, filter: this.paginationParamsModel.filter, sorting: this.paginationParamsModel.sorting }); // Next line is necessary to avoid Bom Loader getting stuck after new Coss was selected. this.messageBus.to('ch:cart', { payload: false }); } /** * Request price and stock for alternates */ public getAlternatesPricing(row: Row): void { // Update the quantity value of each alt row.item.alternates.forEach((alt: ListItemModel) => { alt.quantity = row.item.quantity; }); // Gets alternates ids array to send with payload const alternates = row.item.alternates.map( (alternate: any) => alternate.partId ); const previousPartId = row.item?.previousPart?.partId; if (previousPartId && !alternates.includes(previousPartId)) { alternates.push(row.item.previousPart.partId); } // Reset prices counter to 0, before doing a new getAlts call this.altPricesCount = 0; const params: any = { partIds: alternates, requestedQuantity: row.item.quantity }; return params; } public trackDataSheet(id: number) { if (!id || id < -1) return; this.analyticsService.googleTrackEvent(AnalyticsEvent.CLICK, { action: 'Download', category: 'Datasheets', label: id }); } public editMultiplier(row: Row) { // Disable showing the qty edit modal if not multiplier or item/prices still loading & if not a match if (row.item.multiplier === 1 || row.item.priceSourceLoading || row.item.isLoading) return; this.buyingOptionsModal(row); } /** * On click buy, trigger event (handled elsewhere) * @param row row to be added to cart * @returns false to prevent default */ public clickedBuy(row: Row): any { const eventType = 'Add to cart'; this.loading = true; return this.cart2 .add( this.bom.id, { rowIds: [row.id], selectionInverted: false }, this.bom.parts, eventType ) .subscribe(() => (this.loading = false)); } public onDelete(item?: ListItemModel) { if (item && item.isLoading) return; this.bomItemService.deleteItemClicked(this.bom, item); } public undoDelete(row: Row) { if (row.loading || !this.permissions.canWrite) return; // Setting isDeleted to false straight away to show the loader (isDeleted is overwritten when the part data comes in) // TODO no error handling if undelete fails - no idea what backend sends this.bom.amendRow(row.item.rowId, { isDeleted: false }); this.bomItemService .saveDeleteStatus(row.bomId.toString(), [row.item.rowId], false) .pipe(take(1)) .subscribe(() => { row.item.isDeleted = false; }); row.loading = true; row.item.isLoading = true; } // Selection stuff public updateSelection(selectionObj) { this.bomItemService.updateSelection(selectionObj.rowId, selectionObj.status, this.bom.parts); if (!this.bomItemService.allSelected) this.isIndeterminate = false; if (this.bomItemService.isPartialSelection(this.bom.parts)) this.isIndeterminate = true; } public startAutoCloseHandler() { this.autoCloseTimer = setTimeout(() => { // this.toggleSelectOptions(false); }, 3000); } public cancelAutoTimeout() { if (!!this.autoCloseTimer) { clearTimeout(this.autoCloseTimer); this.autoCloseTimer = null; } } /** * Sets form control quantity value and sends update row request (qty updated on successful response) * * @param qtyObj The object with quantity and the row * TODO: DOES NOT BELONG HERE! NEEDS REFACTOR */ public setQuantity(qtyObj): void { qtyObj.quantity = parseInt(qtyObj.quantity, 10); const multipliedQuantity = qtyObj.quantity * qtyObj.row.item.multiplier; // If incorrect value, ignore changes, revert to original or 1 & update quantity form control // If nothing has changed, don't bother updating if (isNaN(qtyObj.quantity) || qtyObj.quantity < 1 || qtyObj.quantity === qtyObj.row.item.singleBomQuantity) { return; } // Set the item quantity immediately // This way if alternate parts are received before any other row info (including the new qty) // then the alts will request pricing with the new qty, instead of previous qty qtyObj.row.item.quantity = multipliedQuantity; // Reset alt pricing, since now it's out of date this.altPricesCount = 0; qtyObj.row.item.altPrices = false; // Only if matched part, send update quantity request & subscribe to response // Set price loading state, bom not fully loaded (waiting for data) && cart not updated yet states if (qtyObj.row.item.fullPart) { qtyObj.row.item.setPriceItemModelLoading(true); this.messageBus .to('ch:loader', { payload: true }) .to('ch:filters', { payload: true }) .to('ch:cart', { payload: true }); // !Important: came from arrow.com this.sockets.emitAction(ApiSocketEmitAction.UPDATE_ROW, { bomId: this.bom.id, rowId: qtyObj.row.item.rowId, pn: null, mfr: null, qty: qtyObj.quantity, pid: -1, pageNum: this.paginationParamsModel.currentPage, perPage: this.paginationParamsModel.itemsPerPage, filter: this.paginationParamsModel.filter, sorting: this.paginationParamsModel.sorting }); this.messageBus.to('ch:cart', { payload: false }); } } /** * If sockets rowUpdate unsuccessful, if this particular row failed, revert partName formControl * to its original name (TODO tis wrong? test?), revert qty formControl to original qty, partname to original name, * stop price loading * @param errorData - error data from the BE apiError * @param row The row */ public onRowUpdateError(row: Row, errorData: any) { const { action, rowId } = errorData; if (action === ApiSocketEmitAction.UPDATE_ROW && rowId === row.id) { row.item.setPriceItemModelLoading(false); row.control.get('quantity').setValue(row.item.quantity); // in case of qty update, revert qty row.control.get('partName').setValue(row.item.originalName); // in case of qty update, revert qty row.item.partName = row.item.originalName; this.loggerService.warn(errorData); } } /** * Resets Angular form controls for editable fields (to enable form validation & usage of angular form features) * Used to refresh form elements after the item model is updated * @param row The list row */ public resetFormControls(row: Row): void { if (!row || !row.item) return; row.control = new FormGroup({ partName: new FormControl(row.item.partName || row.item.originalName), stock: new FormControl(row.item.stock), minimum: new FormControl(row.item.minQuantity), batch: new FormControl(row.item.increment), // !Important // TODO: watch below - quantity reset was enabled in arrow.com only quantity: new FormControl(row.item.quantity) }); } /** * Check if item quantity is valid (if prices loaded, within stock, over min quantity & within increment) * @returns quantity valid true/false */ public isQuantityValid(item: ListItemModel): boolean { // TODO: This validation is working backwards, return true when invalid and false when valid. Need fix if (this.siteTarget === 'myarrow') { // When in MyArrow mode we don't care about stock return ( !item.priceSourceLoading && (!!((item.quantity + item.inCart) % item.increment) || item.quantity + item.inCart < item.minQuantity) ); } return ( !item.priceSourceLoading && (!!!item.stock || item.quantity + item.inCart > item.stock || !!((item.quantity + item.inCart) % item.increment) || item.quantity + item.inCart < item.minQuantity) ); } public getColumnByName(feColumnName: string): string { let name: string; switch (feColumnName) { case 'user uploaded data': name = 'MPN'; break; case 'part number': name = 'MPN'; break; case 'price': name = 'PRICE'; break; case 'quantity': name = 'QTY'; break; case 'stock': name = 'STOCK'; break; default: name = 'NOT DEFINED'; } return name; } /** * When Bom got all the data, update cart info/contents, set bom to fully loaded, clean up still loading items (for whatever reason) * Get filters for the bom */ public onBomLoadingComplete() { this.isPendingAddMulti = false; this.messageBus.to('ch:loader', { payload: false }); this.setRemainingItemsToLoaded(); this.partInfo$ = this.bomService.getPartInfoHttp(); } /** * Set all remaining loading items to fully loaded, you're not getting any more data */ public setRemainingItemsToLoaded() { // Get still loading items (ids) and set them fully loaded if (this.bom.rows && this.bom.rows.length > 0) { this.bom.rows .filter(row => row.isLoading || row.priceSourceLoading) .forEach(row => { row.setItemModelLoading(false); row.setPriceItemModelLoading(false); }); } } public updateBomTotals() { // Refresh the filters when bom data is changed this.sockets.emitAction(ApiSocketEmitAction.GET_PAGE_COUNTS, { bomId: this.bom.id, pageNum: this.paginationParamsModel.currentPage, perPage: this.paginationParamsModel.itemsPerPage, filter: this.paginationParamsModel.filter, sorting: this.paginationParamsModel.sorting }); } public onFilterChanged(selectedFilters: Object) { this.paginationParamsModel.filter = selectedFilters; this.getBomData(CACHE_DATA_STATE); } /** * Shows modal message. * @param data: { title: 'title', message: 'message' } */ public showMessage(data: any): void { this.dialogService.alert(data.title || this.translate.instant('An error has occurred'), data.message); } private needToRefreshData(): boolean { const cacheTimespan = 15 * 60000; return ( this.firstBomLoad || new Date().getTime() - this.loadTimestamp.getTime() > cacheTimespan ); } /** * ListComponent code */ private mergeData(list: Row[], rows: ListItemModel[]): any[] { if (!list || !rows) return; rows.forEach((item: ListItemModel) => { const rowMatch: Row = list.find((row: Row) => { return row.id === item.rowId; }); if (rowMatch) { rowMatch.item = item; this.resetFormControls(rowMatch); } }); this.loading = false; return this.bomItemService.toggleRowsSelection(list.slice()).slice(); } }