From 30c98ad435d4126ff09c43be98408cf203c244a0 Mon Sep 17 00:00:00 2001 From: Zeeshan Dawood Date: Mon, 6 Aug 2018 09:17:56 -0500 Subject: [PATCH] ReOrder ability (#118) * merge fix * remove /src/UI/Buyer/.gitIgnore * remove karma.conf.js * remove package-lock.json * remove package.json * remove readme * these files were moved * Feat: Reorder an Order This is a container component which takes in a order id. validate the line items on the order, then display which items that are invalid and an add to cart button. Use Container because it calls out to the lineItemService to create line items with the valid products. * Update reorder component to work with breaking changes Unit test the order-reorder container * refactor app lineitem service * updated asked for changes * Add Unit tests update the service to throw an error if no idea is there. --- docs/PULL_REQUEST_TEMPLATE.md | 4 +- .../order-reorder.component.html | 25 +++ .../order-reorder.component.scss | 0 .../order-reorder.component.spec.ts | 113 ++++++++++++++ .../order-reorder/order-reorder.component.ts | 78 ++++++++++ .../containers/order/order.component.html | 2 + src/UI/Buyer/src/app/order/order.module.ts | 2 + src/UI/Buyer/src/app/shared/index.ts | 1 + .../oc-reorder/oc-reorder.interface.ts | 6 + .../oc-reorder/oc-reorder.service.spec.ts | 143 ++++++++++++++++++ .../services/oc-reorder/oc-reorder.service.ts | 104 +++++++++++++ src/UI/Buyer/src/app/shared/shared.module.ts | 2 + 12 files changed, 478 insertions(+), 2 deletions(-) create mode 100644 src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.html create mode 100644 src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.scss create mode 100644 src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.spec.ts create mode 100644 src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.ts create mode 100644 src/UI/Buyer/src/app/shared/services/oc-reorder/oc-reorder.interface.ts create mode 100644 src/UI/Buyer/src/app/shared/services/oc-reorder/oc-reorder.service.spec.ts create mode 100644 src/UI/Buyer/src/app/shared/services/oc-reorder/oc-reorder.service.ts diff --git a/docs/PULL_REQUEST_TEMPLATE.md b/docs/PULL_REQUEST_TEMPLATE.md index 5ced1b21..23586890 100644 --- a/docs/PULL_REQUEST_TEMPLATE.md +++ b/docs/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,7 @@ # Description -- Summary of the change and which issue is fixed. -- Relevant motivation and context. +- Summary of the change and which issue is fixed. +- Relevant motivation and context. - List any dependencies that are required for this change. - Reference to related issues diff --git a/src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.html b/src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.html new file mode 100644 index 00000000..08225d66 --- /dev/null +++ b/src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.html @@ -0,0 +1,25 @@ + + +
+
+

+
+ + + + + +
+
\ No newline at end of file diff --git a/src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.scss b/src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.spec.ts b/src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.spec.ts new file mode 100644 index 00000000..ce3975b9 --- /dev/null +++ b/src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.spec.ts @@ -0,0 +1,113 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { OrderReorderComponent } from '@app-buyer/order/containers/order-reorder/order-reorder.component.ts'; +import { + ModalService, + AppReorderService, + AppLineItemService, +} from '@app-buyer/shared'; +import { of } from 'rxjs'; + +describe('OrderReorderComponent', () => { + let component: OrderReorderComponent; + let fixture: ComponentFixture; + let reorderResponse$; + let AppReorderServiceTest = null; + + const modalServiceTest = { + open: jasmine.createSpy('open').and.returnValue(of({})), + close: jasmine.createSpy('close'), + }; + + const AppLineItemServiceTest = { + create: jasmine.createSpy('create').and.returnValue(of({})), + }; + + beforeEach(async(() => { + AppReorderServiceTest = { + order: jasmine.createSpy('order').and.returnValue( + of({ + ValidLi: [{ Product: {}, Quantity: 2 }, { Product: {}, Quantity: 2 }], + InvalidLi: [], + }) + ), + }; + TestBed.configureTestingModule({ + declarations: [OrderReorderComponent], + providers: [ + { provide: ModalService, useValue: modalServiceTest }, + { provide: AppReorderService, useValue: AppReorderServiceTest }, + { provide: AppLineItemService, useValue: AppLineItemServiceTest }, + ], + schemas: [NO_ERRORS_SCHEMA], // Ignore template errors: remove if tests are added to test template + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(OrderReorderComponent); + component = fixture.componentInstance; + component.orderID = 'orderID'; + fixture.detectChanges(); + }); + + afterEach(() => { + AppReorderServiceTest = null; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit with order Input', () => { + it('should call the OrderReorderService', () => { + component.ngOnInit(); + expect(AppReorderServiceTest.order).toHaveBeenCalled(); + }); + }); + + describe('ngOnInit with no order Input', () => { + it('should call toastr Error', () => { + component.orderID = null; + fixture.detectChanges(); + expect(() => component.ngOnInit()).toThrow(new Error('Needs Order ID')); + }); + }); + + describe('orderReorder', () => { + it('should call the modalService', () => { + component.orderReorder(); + expect(modalServiceTest.open).toHaveBeenCalled(); + }); + }); + + describe('addToCart', () => { + it('should not call the li service create ', () => { + AppReorderServiceTest.order.and.returnValue( + of({ + ValidLi: [], + InvalidLi: [], + }) + ); + fixture.detectChanges(); + component.reorderResponse$ = AppReorderServiceTest.order(); + component.ngOnInit(); + component.addToCart(); + expect(AppLineItemServiceTest.create).not.toHaveBeenCalled(); + }); + + it('should call the li create service the correct amount of times', () => { + AppReorderServiceTest.order.and.returnValue( + of({ + ValidLi: [{ Product: {}, Quantity: 2 }, { Product: {}, Quantity: 2 }], + InvalidLi: [], + }) + ); + + reorderResponse$ = AppReorderServiceTest.order(); + component.ngOnInit(); + component.addToCart(); + expect(AppLineItemServiceTest.create).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.ts b/src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.ts new file mode 100644 index 00000000..64230bf2 --- /dev/null +++ b/src/UI/Buyer/src/app/order/containers/order-reorder/order-reorder.component.ts @@ -0,0 +1,78 @@ +import { Component, OnInit, Input, OnDestroy } from '@angular/core'; +import { Observable } from 'rxjs'; +import { takeWhile, tap } from 'rxjs/operators'; + +import { forEach as _forEach } from 'lodash'; + +import { + ModalService, + AppLineItemService, + AppReorderService, +} from '@app-buyer/shared'; +import { orderReorderResponse } from '@app-buyer/shared/services/oc-reorder/oc-reorder.interface'; + +@Component({ + selector: 'order-reorder', + templateUrl: './order-reorder.component.html', + styleUrls: ['./order-reorder.component.scss'], +}) +export class OrderReorderComponent implements OnInit, OnDestroy { + @Input() orderID: string; + reorderResponse$: Observable; + modalID = 'Order-Reorder'; + alive = true; + message = { string: null, classType: null }; + + constructor( + private appReorderService: AppReorderService, + private modalService: ModalService, + private appLineItemService: AppLineItemService + ) {} + + ngOnInit() { + if (this.orderID) { + this.reorderResponse$ = this.appReorderService.order(this.orderID).pipe( + tap((response) => { + this.updateMessage(response); + }) + ); + } else { + throw new Error('Needs Order ID'); + } + } + + updateMessage(response: orderReorderResponse): void { + if (response.InvalidLi.length && !response.ValidLi.length) { + this.message.string = `None of the line items on this order are available for reorder.`; + this.message.classType = 'danger'; + return; + } + if (response.InvalidLi.length && response.ValidLi.length) { + this.message.string = `Warning The following line items are not available for reorder, clicking add to cart will only add valid line items.`; + this.message.classType = 'warning'; + return; + } + this.message.string = `All line items are valid to reorder`; + this.message.classType = 'success'; + } + + orderReorder() { + this.modalService.open(this.modalID); + } + + addToCart() { + this.reorderResponse$ + .pipe(takeWhile(() => this.alive)) + .subscribe((reorderResponse) => { + _forEach(reorderResponse.ValidLi, (li) => { + if (!li) return; + this.appLineItemService.create(li.Product, li.Quantity).subscribe(); + }); + this.modalService.close(this.modalID); + }); + } + + ngOnDestroy() { + this.alive = false; + } +} diff --git a/src/UI/Buyer/src/app/order/containers/order/order.component.html b/src/UI/Buyer/src/app/order/containers/order/order.component.html index c472147f..a2be9ceb 100644 --- a/src/UI/Buyer/src/app/order/containers/order/order.component.html +++ b/src/UI/Buyer/src/app/order/containers/order/order.component.html @@ -7,6 +7,8 @@ {{order.ID}} +

Order #: {{order.ID}}

Submitted on {{order.DateSubmitted | date: 'short'}} diff --git a/src/UI/Buyer/src/app/order/order.module.ts b/src/UI/Buyer/src/app/order/order.module.ts index 3f4fea85..f27b7bcf 100644 --- a/src/UI/Buyer/src/app/order/order.module.ts +++ b/src/UI/Buyer/src/app/order/order.module.ts @@ -12,6 +12,7 @@ import { OrderComponent } from '@app-buyer/order/containers/order/order.componen import { OrderResolve } from '@app-buyer/order/order.resolve'; import { OrderShipmentsComponent } from '@app-buyer/order/containers/order-shipments/order-shipments.component'; import { ShipmentsResolve } from '@app-buyer/order/shipments.resolve'; +import { OrderReorderComponent } from '@app-buyer/order/containers/order-reorder/order-reorder.component'; import { OrderAprovalComponent } from '@app-buyer/order/containers/order-approval/order-approval.component'; import { OrderApprovalDetailsComponent } from './containers/order-approval-details/order-approval-details.component'; @@ -26,6 +27,7 @@ import { OrderApprovalDetailsComponent } from './containers/order-approval-detai StatusIconComponent, OrderComponent, OrderShipmentsComponent, + OrderReorderComponent, OrderAprovalComponent, OrderApprovalDetailsComponent, ], diff --git a/src/UI/Buyer/src/app/shared/index.ts b/src/UI/Buyer/src/app/shared/index.ts index bf3d0ea9..482d0ebf 100644 --- a/src/UI/Buyer/src/app/shared/index.ts +++ b/src/UI/Buyer/src/app/shared/index.ts @@ -27,6 +27,7 @@ export * from '@app-buyer/shared/services/oc-form-error/oc-form-error.service'; export * from '@app-buyer/shared/services/oc-geography/oc-geography.service'; export * from '@app-buyer/shared/services/oc-line-item/oc-line-item.service'; export * from '@app-buyer/shared/services/modal/modal.service'; +export * from '@app-buyer/shared/services/oc-reorder/oc-reorder.service'; // validators export * from '@app-buyer/shared/validators/oc-match-fields/oc-match-fields.validator'; diff --git a/src/UI/Buyer/src/app/shared/services/oc-reorder/oc-reorder.interface.ts b/src/UI/Buyer/src/app/shared/services/oc-reorder/oc-reorder.interface.ts new file mode 100644 index 00000000..645cd127 --- /dev/null +++ b/src/UI/Buyer/src/app/shared/services/oc-reorder/oc-reorder.interface.ts @@ -0,0 +1,6 @@ +import { LineItem } from '@ordercloud/angular-sdk'; + +export interface orderReorderResponse{ + ValidLi: Array, + InvalidLi: Array +} \ No newline at end of file diff --git a/src/UI/Buyer/src/app/shared/services/oc-reorder/oc-reorder.service.spec.ts b/src/UI/Buyer/src/app/shared/services/oc-reorder/oc-reorder.service.spec.ts new file mode 100644 index 00000000..50ad0c3c --- /dev/null +++ b/src/UI/Buyer/src/app/shared/services/oc-reorder/oc-reorder.service.spec.ts @@ -0,0 +1,143 @@ +import { AppStateService } from '@app-buyer/shared/services/app-state/app-state.service'; +import { element } from 'protractor'; +import { async, TestBed, inject } from '@angular/core/testing'; +import { AppLineItemService } from '@app-buyer/shared/services/oc-line-item/oc-line-item.service'; +import { AppReorderService } from '@app-buyer/shared/services/oc-reorder/oc-reorder.service'; +import { orderReorderResponse } from '@app-buyer/shared/services/oc-reorder/oc-reorder.interface'; +import { OcMeService, BuyerProduct, LineItem } from '@ordercloud/angular-sdk'; +import { forEach as _forEach, differenceBy as _differenceBy } from 'lodash'; +import { and } from '../../../../../../node_modules/@angular/router/src/utils/collection'; +import { of } from 'rxjs'; + +describe('ReOrder Service', () => { + let mockLineItems = { + Items: [{ ProductID: 'someID' }, { ProductID: 'someID2' }], + Meta: {}, + }; + let mockReOrderResponse = { + ValidLi: [ + { + ProductID: 'someID', + Product: { + ID: 'someID', + Inventory: { + Enabled: false, + OrderCanExceed: null, + QuantityAvailable: 100, + }, + PriceSchedule: { + RestrictedQuantity: false, + PriceBreaks: { + Quantity: 1, + }, + }, + }, + Quantity: 1, + }, + ], + InvalidLi: [{ ProductID: 'someID2' }], + }; + let mockBuyerProducts = [{ ID: 'someID' }]; + let mockProductIds = ['someID', 'someID2']; + let mockProductIdsJoin = 'someID|someID2'; + + let service; + let response; + let appLineItemService = { listAll: () => {} }; + let meService = { ListProducts: () => {} }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + providers: [ + AppReorderService, + { provide: AppLineItemService, useValue: appLineItemService }, + { provide: OcMeService, useValue: meService }, + ], + }); + service = TestBed.get(AppReorderService); + appLineItemService = TestBed.get(AppLineItemService); + meService = TestBed.get(OcMeService); + })); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('Order', () => { + beforeEach(() => { + spyOn(appLineItemService, 'listAll').and.returnValue(of(mockLineItems)); + spyOn(service, 'getValidProducts').and.returnValue(of(mockBuyerProducts)); + spyOn(service, 'isProductInLiValid').and.returnValue( + of(mockReOrderResponse) + ); + spyOn(service, 'hasInventory').and.returnValue(of(mockReOrderResponse)); + }); + + it('should call appLineItem service with Order ID', () => { + service.order('orderID'); + expect(service.appLineItemService.listAll).toHaveBeenCalledWith( + 'orderID' + ); + }); + + it('should throw an error if there is no argument Passed', () => { + expect(() => service.order(null)).toThrow(new Error('Needs Order ID')); + }); + + it('should call getValidProducts', () => { + service.order('orderID').subscribe(); + expect(service.getValidProducts).toHaveBeenCalledWith(mockProductIds); + }); + + it('should call isProductInLiValid', () => { + service.order('orderID').subscribe(); + expect(service.isProductInLiValid).toHaveBeenCalledWith( + mockBuyerProducts, + mockLineItems.Items + ); + }); + + it('should call hasInventory', () => { + service.order('orderID').subscribe(); + expect(service.hasInventory).toHaveBeenCalledWith(mockReOrderResponse); + }); + }); + + describe('getValidProducts functionality', () => { + beforeEach(() => { + spyOn(meService, 'ListProducts').and.returnValue(of(mockBuyerProducts)); + service['getValidProducts'](mockProductIds); + }); + + it('should call ocMeService ListProducts', () => { + expect(meService.ListProducts).toHaveBeenCalledWith({ + filters: { ID: mockProductIdsJoin }, + }); + }); + }); + + describe('isProductInLiValid functionality', () => { + beforeEach(() => { + spyOn(service, 'isProductInLiValid').and.callThrough(); + response = service['isProductInLiValid']( + mockBuyerProducts, + mockLineItems.Items + ).subscribe; + }); + + it('should return orderReorderResponse', () => { + expect(response).toEqual(of(mockReOrderResponse).subscribe); + }); + }); + + describe('hasInventory functionality', () => { + beforeEach(() => { + spyOn(service, 'hasInventory').and.callThrough(); + response = service['hasInventory'](mockReOrderResponse).subscribe; + }); + + it('orderReorderResponse should return one valid li and one invalid li', () => { + expect(response).toEqual(of(mockReOrderResponse).subscribe); + }); + }); +}); diff --git a/src/UI/Buyer/src/app/shared/services/oc-reorder/oc-reorder.service.ts b/src/UI/Buyer/src/app/shared/services/oc-reorder/oc-reorder.service.ts new file mode 100644 index 00000000..89e4a7ec --- /dev/null +++ b/src/UI/Buyer/src/app/shared/services/oc-reorder/oc-reorder.service.ts @@ -0,0 +1,104 @@ +import { Injectable } from '@angular/core'; +import { AppLineItemService } from '@app-buyer/shared/services/oc-line-item/oc-line-item.service'; +import { orderReorderResponse } from '@app-buyer/shared/services/oc-reorder/oc-reorder.interface'; +import { Observable, of, forkJoin } from 'rxjs'; +import { flatMap } from 'rxjs/operators'; +import { OcMeService, BuyerProduct, LineItem } from '@ordercloud/angular-sdk'; +import { forEach as _forEach, differenceBy as _differenceBy } from 'lodash'; + +@Injectable() +export class AppReorderService { + constructor( + private appLineItemService: AppLineItemService, + private meService: OcMeService + ) {} + + public order(orderID: string): Observable { + if (!orderID) throw new Error('Needs Order ID'); + return this.appLineItemService.listAll(orderID).pipe( + flatMap((list) => { + let lineItems = of(list.Items); // this sets var into an observable + let productIds = list.Items.map((item) => item.ProductID); + let validProducts = this.getValidProducts(productIds); + return forkJoin([validProducts, lineItems]); + }), + flatMap((results) => this.isProductInLiValid(results[0], results[1])), + flatMap((results) => this.hasInventory(results)) + ); + } + + private getValidProducts( + productIds: string[], + validProducts: BuyerProduct[] = [] + ): Observable { + validProducts = validProducts; + let chunk = productIds.splice(0, 25); + return this.meService + .ListProducts({ filters: { ID: chunk.join('|') } }) + .pipe( + flatMap((productList) => { + validProducts = validProducts.concat(productList.Items); + if (productIds.length) { + return this.getValidProducts(productIds, validProducts); + } else { + return of(validProducts); + } + }) + ); + } + + private isProductInLiValid( + products: BuyerProduct[], + lineItems: LineItem[] + ): Observable { + let validProductIDs = products.map((p) => p.ID); + let validLi: LineItem[] = []; + let invalidLi: LineItem[] = []; + + _forEach(lineItems, (li) => { + if (validProductIDs.indexOf(li.ProductID) > -1) { + let product = products.find((p) => p.ID == li.ProductID); + li.Product = product; + validLi.push(li); + } else { + invalidLi.push(li); + } + }); + return of({ ValidLi: validLi, InvalidLi: invalidLi }); + } + + private hasInventory( + response: orderReorderResponse + ): Observable { + // compare new validLi with old validLi and push difference into the new invalid[] + old invalid array. + let newOrderResponse: orderReorderResponse; + let newValidLi = response.ValidLi.filter(isValidToOrder); + let newInvalidLi = _differenceBy(response.ValidLi, newValidLi, 'ProductID'); + + newInvalidLi = newInvalidLi.concat(response.InvalidLi); + newOrderResponse = { ValidLi: newValidLi, InvalidLi: newInvalidLi }; + + return of(newOrderResponse); + + function isValidToOrder(li) { + let restrictedOrderQuantity = li.Product.PriceSchedule.RestrictedQuantity; + let withinPriceBreak; + + if (!restrictedOrderQuantity) { + return validOrderQuantity(li); + } else { + withinPriceBreak = li.Product.PriceSchedule.PriceBreaks.some( + (pb) => pb.Quantity == li.Quantity + ); + return withinPriceBreak && validOrderQuantity(li); + } + } + function validOrderQuantity(li) { + let inventory = li.Product.Inventory; + if (!inventory || !inventory.Enabled || inventory.OrderCanExceed) { + return true; + } + return inventory.QuantityAvailable >= li.Quantity; + } + } +} diff --git a/src/UI/Buyer/src/app/shared/shared.module.ts b/src/UI/Buyer/src/app/shared/shared.module.ts index acb7dd07..7684f6dc 100644 --- a/src/UI/Buyer/src/app/shared/shared.module.ts +++ b/src/UI/Buyer/src/app/shared/shared.module.ts @@ -34,6 +34,7 @@ import { AppLineItemService } from '@app-buyer/shared/services/oc-line-item/oc-l import { AuthorizeNetService } from '@app-buyer/shared/services/authorize-net/authorize-net.service'; import { OcFormErrorService } from '@app-buyer/shared/services/oc-form-error/oc-form-error.service'; import { ModalService } from '@app-buyer/shared/services/modal/modal.service'; +import { AppReorderService } from '@app-buyer/shared/services/oc-reorder/oc-reorder.service'; // pipes import { PhoneFormatPipe } from '@app-buyer/shared/pipes/phone-format/phone-format.pipe'; @@ -201,6 +202,7 @@ export class SharedModule { IsLoggedInGuard, DatePipe, NgbDateCustomParserFormatter, + AppReorderService, { provide: applicationConfiguration, useValue: ocAppConfig }, { provide: ErrorHandler, useClass: AppErrorHandler }, ],