diff --git a/feature-libs/cart/base/occ/adapters/converters/occ-cart-normalizer.spec.ts b/feature-libs/cart/base/occ/adapters/converters/occ-cart-normalizer.spec.ts index 1b0c869a6fc..c926cbb61b3 100644 --- a/feature-libs/cart/base/occ/adapters/converters/occ-cart-normalizer.spec.ts +++ b/feature-libs/cart/base/occ/adapters/converters/occ-cart-normalizer.spec.ts @@ -1,9 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { + Cart, ORDER_ENTRY_PROMOTIONS_NORMALIZER, PromotionResult, } from '@spartacus/cart/base/root'; -import { ConverterService, PRODUCT_NORMALIZER } from '@spartacus/core'; +import { ConverterService, Occ, PRODUCT_NORMALIZER } from '@spartacus/core'; import { OccCartNormalizer } from './occ-cart-normalizer'; class MockConverterService { @@ -88,4 +89,23 @@ describe('OccCartNormalizer', () => { }, ]); }); + describe('handleQuote', () => { + const quoteCode = '00100092'; + const source: Occ.Cart = { sapQuote: { code: quoteCode } }; + + it('should set quote code if sapQuote is present in OCC response', () => { + const target: Cart = {}; + occCartNormalizer['handleQuote'](source, target); + expect(target.quoteCode).toBe(quoteCode); + }); + + it('should ignore missing sapQuote in OCC response', () => { + const target: Cart = {}; + occCartNormalizer['handleQuote']( + { ...source, sapQuote: undefined }, + target + ); + expect(target.quoteCode).toBe(undefined); + }); + }); }); diff --git a/feature-libs/cart/base/occ/adapters/converters/occ-cart-normalizer.ts b/feature-libs/cart/base/occ/adapters/converters/occ-cart-normalizer.ts index 4210da7c173..8463e3d76b1 100644 --- a/feature-libs/cart/base/occ/adapters/converters/occ-cart-normalizer.ts +++ b/feature-libs/cart/base/occ/adapters/converters/occ-cart-normalizer.ts @@ -26,6 +26,7 @@ export class OccCartNormalizer implements Converter { } this.removeDuplicatePromotions(source, target); + this.handleQuote(source, target); if (source.entries) { target.entries = source.entries.map((entry) => ({ @@ -41,6 +42,12 @@ export class OccCartNormalizer implements Converter { return target; } + protected handleQuote(source: Occ.Cart, target: Cart) { + if (source.sapQuote) { + target.quoteCode = source.sapQuote.code; + } + } + /** * Remove all duplicate promotions */ diff --git a/feature-libs/cart/base/root/models/cart.model.ts b/feature-libs/cart/base/root/models/cart.model.ts index e61c6ccb102..ccd5c549df8 100644 --- a/feature-libs/cart/base/root/models/cart.model.ts +++ b/feature-libs/cart/base/root/models/cart.model.ts @@ -97,6 +97,7 @@ export interface Cart { totalTax?: Price; totalUnitCount?: number; user?: Principal; + quoteCode?: string; } export interface CartModification { diff --git a/feature-libs/quote/components/details/cart/summary/quote-details-cart-summary.component.spec.ts b/feature-libs/quote/components/details/cart/summary/quote-details-cart-summary.component.spec.ts index ec0482c74e9..1349f9e2eb8 100644 --- a/feature-libs/quote/components/details/cart/summary/quote-details-cart-summary.component.spec.ts +++ b/feature-libs/quote/components/details/cart/summary/quote-details-cart-summary.component.spec.ts @@ -66,7 +66,7 @@ class MockCommerceQuotesFacade implements Partial { return mockQuoteDetails$.asObservable(); } performQuoteAction( - _quoteCode: string, + _quote: Quote, _quoteAction: QuoteActionType ): Observable { return EMPTY; diff --git a/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.spec.ts b/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.spec.ts index 61eae5064fe..d58a762fe3e 100644 --- a/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.spec.ts +++ b/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.spec.ts @@ -90,7 +90,7 @@ class MockCommerceQuotesFacade implements Partial { return mockQuoteDetails$.asObservable(); } performQuoteAction( - _quoteCode: string, + _quote: Quote, _quoteAction: QuoteActionType ): Observable { return EMPTY; @@ -359,7 +359,7 @@ describe('QuoteActionsByRoleComponent', () => { component.onClick(QuoteActionType.SUBMIT, newMockQuoteWithSubmitAction); launchDialogService.closeDialog('yes'); expect(facade.performQuoteAction).toHaveBeenCalledWith( - newMockQuoteWithSubmitAction.code, + newMockQuoteWithSubmitAction, QuoteActionType.SUBMIT ); }); @@ -373,7 +373,7 @@ describe('QuoteActionsByRoleComponent', () => { ); editButton.click(); expect(facade.performQuoteAction).toHaveBeenCalledWith( - mockQuote.code, + mockQuote, QuoteActionType.EDIT ); }); @@ -474,7 +474,7 @@ describe('QuoteActionsByRoleComponent', () => { component['handleConfirmationDialogClose'](QuoteActionType.EDIT, context); launchDialogService.closeDialog('yes'); expect(facade.performQuoteAction).toHaveBeenCalledWith( - mockCode, + mockQuote, QuoteActionType.EDIT ); expect(globalMessageService.add).not.toHaveBeenCalled(); @@ -486,7 +486,7 @@ describe('QuoteActionsByRoleComponent', () => { ); launchDialogService.closeDialog('yes'); expect(facade.performQuoteAction).toHaveBeenCalledWith( - mockCode, + mockQuote, QuoteActionType.SUBMIT ); expect(globalMessageService.add).toHaveBeenCalledWith( diff --git a/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.ts b/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.ts index 3c2e990f0ed..1db6d6e9dee 100644 --- a/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.ts +++ b/feature-libs/quote/components/quote-actions-by-role/quote-actions-by-role.component.ts @@ -89,7 +89,7 @@ export class QuoteActionsByRoleComponent implements OnInit, OnDestroy { performAction(action: QuoteActionType, quote: Quote) { if (!this.isConfirmationDialogRequired(action, quote.state)) { - this.quoteFacade.performQuoteAction(quote.code, action); + this.quoteFacade.performQuoteAction(quote, action); return; } @@ -118,9 +118,7 @@ export class QuoteActionsByRoleComponent implements OnInit, OnDestroy { this.launchDialogService.dialogClose .pipe( filter((reason) => reason === 'yes'), - tap(() => - this.quoteFacade.performQuoteAction(context.quote.code, action) - ), + tap(() => this.quoteFacade.performQuoteAction(context.quote, action)), filter(() => !!context.successMessage), tap(() => this.globalMessageService.add( @@ -140,7 +138,7 @@ export class QuoteActionsByRoleComponent implements OnInit, OnDestroy { : GlobalMessageType.MSG_TYPE_CONFIRMATION; } - requote(quoteId: string) { + protected requote(quoteId: string) { this.quoteFacade.requote(quoteId); } diff --git a/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.spec.ts b/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.spec.ts index e89285e98cf..be3b438d37b 100644 --- a/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.spec.ts +++ b/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.spec.ts @@ -195,7 +195,7 @@ describe('QuoteRequestDialogComponent', () => { }); expect(quoteFacade.performQuoteAction).toHaveBeenCalledWith( - quoteCode, + mockCreatedQuote, QuoteActionType.SUBMIT ); expect(launchDialogService.closeDialog).toHaveBeenCalledWith('success'); diff --git a/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.ts b/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.ts index e89cf0f047f..1662ee2f410 100644 --- a/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.ts +++ b/feature-libs/quote/components/quote-request-dialog/quote-request-dialog.component.ts @@ -79,10 +79,7 @@ export class QuoteRequestDialogComponent { params: { quoteId: quote.code }, }); } else { - this.quoteFacade.performQuoteAction( - quote.code, - QuoteActionType.SUBMIT - ); + this.quoteFacade.performQuoteAction(quote, QuoteActionType.SUBMIT); } this.dismissModal('success'); }) diff --git a/feature-libs/quote/core/facade/quote.service.spec.ts b/feature-libs/quote/core/facade/quote.service.spec.ts index 3928b486913..f78843cf79a 100644 --- a/feature-libs/quote/core/facade/quote.service.spec.ts +++ b/feature-libs/quote/core/facade/quote.service.spec.ts @@ -1,6 +1,10 @@ import { inject, TestBed } from '@angular/core/testing'; import { Params } from '@angular/router'; -import { ActiveCartFacade, MultiCartFacade } from '@spartacus/cart/base/root'; +import { + ActiveCartFacade, + Cart, + MultiCartFacade, +} from '@spartacus/cart/base/root'; import { Comment, Quote, @@ -25,49 +29,57 @@ import { } from '@spartacus/core'; import { ViewConfig } from '@spartacus/storefront'; import { BehaviorSubject, EMPTY, Observable, of } from 'rxjs'; -import { take } from 'rxjs/operators'; +import { switchMap, take } from 'rxjs/operators'; import { QuoteConnector } from '../connectors'; import { QuoteService } from './quote.service'; import { createEmptyQuote, QUOTE_CODE } from '../testing/quote-test-utils'; import createSpy = jasmine.createSpy; import { CartUtilsService } from '../services/cart-utils.service'; -const mockUserId = OCC_USER_ID_CURRENT; -const mockCartId = '1234'; -const mockAction = { type: QuoteActionType.EDIT, isPrimary: true }; -const mockCurrentPage = 0; -const mockSort = 'byCode'; -const mockPagination: PaginationModel = { - currentPage: mockCurrentPage, +const userId = OCC_USER_ID_CURRENT; +const cartId = '1234'; +const quoteAction = { type: QuoteActionType.EDIT, isPrimary: true }; +const currentPageIndex = 0; +const sortCode = 'byCode'; +const pagination: PaginationModel = { + currentPage: currentPageIndex, pageSize: 5, - sort: mockSort, + sort: sortCode, }; -const mockQuote: Quote = { +const quote: Quote = { ...createEmptyQuote(), - allowedActions: [mockAction], - cartId: mockCartId, + allowedActions: [quoteAction], + cartId: cartId, code: '333333', }; -const mockQuoteList: QuoteList = { - pagination: mockPagination, +const quoteWithoutCartId: Quote = { + ...quote, + cartId: undefined, +}; +const cart: Cart = { + code: cartId, +}; + +const quoteList: QuoteList = { + pagination: pagination, sorts: [{ code: 'byDate' }], - quotes: [mockQuote], + quotes: [quote], }; -const mockParams = { ['quoteId']: '1' }; +const routeParams = { ['quoteId']: '1' }; const mockRouterState$ = new BehaviorSubject({ navigationId: 1, - state: { params: mockParams as Params }, + state: { params: routeParams as Params }, }); -const mockMetadata: QuoteMetadata = { +const quoteMetaData: QuoteMetadata = { name: 'test', description: 'test desc', }; -const mockComment: Comment = { +const quoteComment: Comment = { text: 'test comment', }; const mockQuotesStateParams: QuotesStateParams = { - sort$: of(mockSort), - currentPage$: of(mockCurrentPage), + sort$: of(sortCode), + currentPage$: of(currentPageIndex), }; class MockRoutingService implements Partial { @@ -78,7 +90,7 @@ class MockRoutingService implements Partial { } class MockUserIdService implements Partial { - takeUserId = createSpy().and.returnValue(of(mockUserId)); + takeUserId = createSpy().and.returnValue(of(userId)); } class MockEventService implements Partial { @@ -100,13 +112,13 @@ class MockQuoteCartService { } } class MockViewConfig implements ViewConfig { - view = { defaultPageSize: mockPagination.pageSize }; + view = { defaultPageSize: pagination.pageSize }; } -class MockCommerceQuotesConnector implements Partial { - getQuotes = createSpy().and.returnValue(of(mockQuoteList)); - getQuote = createSpy().and.returnValue(of(mockQuote)); - createQuote = createSpy().and.returnValue(of(mockQuote)); +class MockQuoteConnector implements Partial { + getQuotes = createSpy().and.returnValue(of(quoteList)); + getQuote = createSpy().and.returnValue(of(quote)); + createQuote = createSpy().and.returnValue(of(quote)); editQuote = createSpy().and.returnValue(of(EMPTY)); addComment = createSpy().and.returnValue(of(EMPTY)); addCartEntryComment = createSpy().and.returnValue(of(EMPTY)); @@ -116,7 +128,9 @@ class MockCommerceQuotesConnector implements Partial { class MockActiveCartService implements Partial { reloadActiveCart = createSpy().and.stub(); - takeActiveCartId = createSpy().and.returnValue(of(mockCartId)); + takeActiveCartId = createSpy().and.returnValue(of(cartId)); + isStable = createSpy().and.returnValue(of(true)); + getActive = createSpy().and.returnValue(of(cart)); } class MockMultiCartFacade implements Partial { @@ -139,6 +153,7 @@ describe('QuoteService', () => { let eventService: EventService; let config: ViewConfig; let multiCartFacade: MultiCartFacade; + let activeCartFacade: ActiveCartFacade; let routingService: RoutingService; let quoteCartService: QuoteCartService; let cartUtilsService: CartUtilsService; @@ -152,7 +167,7 @@ describe('QuoteService', () => { { provide: ViewConfig, useClass: MockViewConfig }, { provide: QuoteConnector, - useClass: MockCommerceQuotesConnector, + useClass: MockQuoteConnector, }, { provide: RoutingService, useClass: MockRoutingService }, { provide: ActiveCartFacade, useClass: MockActiveCartService }, @@ -168,6 +183,7 @@ describe('QuoteService', () => { eventService = TestBed.inject(EventService); config = TestBed.inject(ViewConfig); multiCartFacade = TestBed.inject(MultiCartFacade); + activeCartFacade = TestBed.inject(ActiveCartFacade); routingService = TestBed.inject(RoutingService); quoteCartService = TestBed.inject(QuoteCartService); cartUtilsService = TestBed.inject(CartUtilsService); @@ -176,6 +192,27 @@ describe('QuoteService', () => { quoteId = ''; }); + function checkQuoteCartFacadeCalls() { + expect(multiCartFacade.loadCart).toHaveBeenCalledWith({ + cartId: cartId, + userId: userId, + extraData: { active: true }, + }); + expect(activeCartFacade.getActive).toHaveBeenCalled(); + } + + function checkNoActionPerforming( + quoteActionResult: Observable, + done: any + ) { + quoteActionResult + .pipe(switchMap(() => service['isActionPerforming$'])) + .subscribe((isPerforming) => { + expect(isPerforming).toBe(false); + done(); + }); + } + it('should inject CommerceQuotesService', inject( [QuoteService], (quoteService: QuoteService) => { @@ -188,14 +225,11 @@ describe('QuoteService', () => { .getQuotesState(mockQuotesStateParams) .pipe(take(1)) .subscribe((state) => { - expect(connector.getQuotes).toHaveBeenCalledWith( - mockUserId, - mockPagination - ); + expect(connector.getQuotes).toHaveBeenCalledWith(userId, pagination); expect(state).toEqual(>{ loading: false, error: false, - data: mockQuoteList, + data: quoteList, }); }); }); @@ -209,14 +243,14 @@ describe('QuoteService', () => { .getQuotesState(mockQuotesStateParams) .pipe(take(1)) .subscribe((state) => { - expect(connector.getQuotes).toHaveBeenCalledWith(mockUserId, { - ...mockPagination, + expect(connector.getQuotes).toHaveBeenCalledWith(userId, { + ...pagination, pageSize: undefined, }); expect(state).toEqual(>{ loading: false, error: false, - data: mockQuoteList, + data: quoteList, }); }); }); @@ -239,10 +273,10 @@ describe('QuoteService', () => { .pipe(take(1)) .subscribe((details) => { expect(connector.getQuote).toHaveBeenCalledWith( - mockUserId, - mockParams.quoteId + userId, + routeParams.quoteId ); - expect(details.data).toEqual(mockQuote); + expect(details.data).toEqual(quote); expect(details.loading).toBe(false); }); }); @@ -254,28 +288,22 @@ describe('QuoteService', () => { .pipe(take(1)) .subscribe((details) => { expect(connector.getQuote).toHaveBeenCalledWith( - mockUserId, - mockParams.quoteId + userId, + routeParams.quoteId ); - expect(details).toEqual(mockQuote); + expect(details).toEqual(quote); }); }); - it('should load quote cart in quote is linked to current quote cart', (done) => { + it('should wait until active cart has been loaded', (done) => { isQuoteCartActive = true; - quoteId = mockQuote.code; + quoteId = quote.code; service .getQuoteDetails() .pipe(take(1)) .subscribe((details) => { - expect(multiCartFacade.loadCart).toHaveBeenCalledWith({ - userId: mockUserId, - cartId: mockQuote.cartId, - extraData: { - active: true, - }, - }); - expect(details).toEqual(mockQuote); + expect(activeCartFacade.isStable).toHaveBeenCalled(); + expect(details).toEqual(quote); done(); }); }); @@ -286,13 +314,13 @@ describe('QuoteService', () => { discountRate: 1, discountType: QuoteDiscountType.ABSOLUTE, }; - it('should ', () => { + it('should call respective connector method ', () => { service .addDiscount(QUOTE_CODE, discount) .pipe(take(1)) .subscribe(() => { expect(connector.addDiscount).toHaveBeenCalledWith( - mockUserId, + userId, QUOTE_CODE, discount ); @@ -302,11 +330,11 @@ describe('QuoteService', () => { it('should call createQuote command', () => { service - .createQuote(mockMetadata) + .createQuote(quoteMetaData) .pipe(take(1)) .subscribe((quote) => { expect(connector.createQuote).toHaveBeenCalled(); - expect(quote.code).toEqual(mockQuote.code); + expect(quote.code).toEqual(quote.code); expect(connector.editQuote).toHaveBeenCalled(); expect(multiCartFacade.loadCart).toHaveBeenCalled(); expect(eventService.dispatch).toHaveBeenCalled(); @@ -315,156 +343,228 @@ describe('QuoteService', () => { it('should call editQuote command', () => { service - .editQuote(mockQuote.code, mockMetadata) + .editQuote(quote.code, quoteMetaData) .pipe(take(1)) .subscribe(() => { expect(connector.editQuote).toHaveBeenCalledWith( - mockUserId, - mockQuote.code, - mockMetadata + userId, + quote.code, + quoteMetaData ); }); }); it('should call addQuoteComment command', () => { service - .addQuoteComment(mockQuote.code, mockComment) + .addQuoteComment(quote.code, quoteComment) .pipe(take(1)) .subscribe(() => { expect(connector.addComment).toHaveBeenCalledWith( - mockUserId, - mockQuote.code, - mockComment + userId, + quote.code, + quoteComment ); }); }); describe('performQuoteAction', () => { it('should call respective connector method', (done) => { - service - .performQuoteAction(mockQuote.code, mockAction.type) - .subscribe(() => { - expect(connector.performQuoteAction).toHaveBeenCalledWith( - mockUserId, - mockQuote.code, - mockAction.type - ); - done(); - }); + service.performQuoteAction(quote, quoteAction.type).subscribe(() => { + expect(connector.performQuoteAction).toHaveBeenCalledWith( + userId, + quote.code, + quoteAction.type + ); + done(); + }); }); it('should raise re-load event', (done) => { - service - .performQuoteAction(mockQuote.code, mockAction.type) - .subscribe(() => { - expect(eventService.dispatch).toHaveBeenCalledWith( - {}, - QuoteDetailsReloadQueryEvent - ); - done(); - }); + service.performQuoteAction(quote, quoteAction.type).subscribe(() => { + expect(eventService.dispatch).toHaveBeenCalledWith( + {}, + QuoteDetailsReloadQueryEvent + ); + done(); + }); }); - it('should reset cart quote mode on submit, create new cart and navigate to quote list', (done) => { - service - .performQuoteAction(mockQuote.code, QuoteActionType.SUBMIT) - .subscribe(() => { - expect(quoteCartService.setQuoteCartActive).toHaveBeenCalledWith( - false - ); - expect( - cartUtilsService.createNewCartAndGoToQuoteList - ).toHaveBeenCalled(); - done(); - }); + describe('on submit', () => { + it('should create new cart and navigate to quote list', (done) => { + service + .performQuoteAction(quote, QuoteActionType.SUBMIT) + .subscribe(() => { + expect( + cartUtilsService.createNewCartAndGoToQuoteList + ).toHaveBeenCalled(); + done(); + }); + }); + + it('should set loading state to false when action is completed', (done) => { + checkNoActionPerforming( + service.performQuoteAction(quote, QuoteActionType.SUBMIT), + done + ); + }); }); - it('should reset cart quote mode on cancel, create new cart and navigate to quote list', (done) => { - service - .performQuoteAction(mockQuote.code, QuoteActionType.CANCEL) - .subscribe(() => { - expect(quoteCartService.setQuoteCartActive).toHaveBeenCalledWith( - false - ); - expect( - cartUtilsService.createNewCartAndGoToQuoteList - ).toHaveBeenCalled(); - done(); - }); + describe('on cancel', () => { + it('should create new cart and navigate to quote list', (done) => { + service + .performQuoteAction(quote, QuoteActionType.CANCEL) + .subscribe(() => { + expect( + cartUtilsService.createNewCartAndGoToQuoteList + ).toHaveBeenCalled(); + done(); + }); + }); + + it('should set loading state to false when action is completed', (done) => { + checkNoActionPerforming( + service.performQuoteAction(quote, QuoteActionType.CANCEL), + done + ); + }); }); - it('should set cart quote mode on edit', (done) => { - service - .performQuoteAction(mockQuote.code, QuoteActionType.EDIT) - .subscribe(() => { - expect(quoteCartService.setQuoteCartActive).toHaveBeenCalledWith( - true - ); - expect(quoteCartService.setQuoteId).toHaveBeenCalledWith( - mockQuote.code - ); - done(); - }); + describe('on edit', () => { + it('should load quote cart', (done) => { + service + .performQuoteAction(quote, QuoteActionType.EDIT) + .subscribe(() => { + checkQuoteCartFacadeCalls(); + done(); + }); + }); + + it('should trigger a quote refresh', (done) => { + service + .performQuoteAction(quote, QuoteActionType.EDIT) + .subscribe(() => { + expect(eventService.dispatch).toHaveBeenCalledWith( + {}, + QuoteDetailsReloadQueryEvent + ); + done(); + }); + }); + + it('should trigger quote re-read in case quote does not carry a cart id', (done) => { + service + .performQuoteAction(quoteWithoutCartId, QuoteActionType.EDIT) + .subscribe(() => { + expect(connector.getQuote).toHaveBeenCalledWith(userId, quote.code); + done(); + }); + }); + + it('should set loading state to false when action is completed', (done) => { + checkNoActionPerforming( + service.performQuoteAction(quote, QuoteActionType.EDIT), + done + ); + }); }); - it('should set cart quote mode on checkout and signal that checkout is allowed', (done) => { - service - .performQuoteAction(mockQuote.code, QuoteActionType.CHECKOUT) - .subscribe(() => { - expect(quoteCartService.setQuoteCartActive).toHaveBeenCalledWith( - true - ); - expect(quoteCartService.setQuoteId).toHaveBeenCalledWith( - mockQuote.code - ); - expect(quoteCartService.setCheckoutAllowed).toHaveBeenCalledWith( - true - ); - done(); - }); + describe('on checkout', () => { + it('should load cart on checkout and signal that checkout is allowed', (done) => { + service + .performQuoteAction(quote, QuoteActionType.CHECKOUT) + .subscribe(() => { + checkQuoteCartFacadeCalls(); + expect(quoteCartService.setCheckoutAllowed).toHaveBeenCalledWith( + true + ); + done(); + }); + }); + + it('should navigate to checkout', (done) => { + service + .performQuoteAction(quote, QuoteActionType.CHECKOUT) + .subscribe(() => { + expect(routingService.go).toHaveBeenCalledWith({ + cxRoute: 'checkout', + }); + done(); + }); + }); + + it('should set loading state to false when action is completed', (done) => { + checkNoActionPerforming( + service.performQuoteAction(quote, QuoteActionType.CHECKOUT), + done + ); + }); + }); + + describe('on reject', () => { + it('should set loading state to false when action is completed', (done) => { + checkNoActionPerforming( + service.performQuoteAction(quote, QuoteActionType.REJECT), + done + ); + }); }); }); it('should call addQuoteComment command when called with empty string of an entry number', () => { service - .addQuoteComment(mockQuote.code, mockComment, '') + .addQuoteComment(quote.code, quoteComment, '') .pipe(take(1)) .subscribe(() => { expect(connector.addComment).toHaveBeenCalledWith( - mockUserId, - mockQuote.code, - mockComment + userId, + quote.code, + quoteComment ); }); }); it('should call addCartEntryComment command when an entry number is provided', () => { service - .addQuoteComment(mockQuote.code, mockComment, '0') + .addQuoteComment(quote.code, quoteComment, '0') .pipe(take(1)) .subscribe(() => { expect(connector.addCartEntryComment).toHaveBeenCalledWith( - mockUserId, - mockQuote.code, + userId, + quote.code, '0', - mockComment + quoteComment ); }); }); - - it('should call requote command and return new quote', () => { - service - .requote(mockQuote.code) - .pipe(take(1)) - .subscribe((quote) => { - expect(connector.createQuote).toHaveBeenCalledWith(mockUserId, { - quoteCode: mockQuote.code, + describe('requote', () => { + it('should call requote command and return new quote', () => { + service + .requote(quote.code) + .pipe(take(1)) + .subscribe((reQuoted) => { + expect(connector.createQuote).toHaveBeenCalledWith(userId, { + quoteCode: quote.code, + }); + expect(routingService.go).toHaveBeenCalledWith({ + cxRoute: 'quoteDetails', + params: { quoteId: quote.code }, + }); + expect(reQuoted.code).toEqual(quote.code); }); - expect(routingService.go).toHaveBeenCalledWith({ - cxRoute: 'quoteDetails', - params: { quoteId: quote.code }, + }); + + it('should load quote cart', (done) => { + service + .requote(quote.code) + .pipe(take(1)) + .subscribe(() => { + checkQuoteCartFacadeCalls(); + done(); }); - expect(quote.code).toEqual(mockQuote.code); - }); + }); + + it('should set loading state to false when action is completed', (done) => { + checkNoActionPerforming(service.requote(quote.code), done); + }); }); }); diff --git a/feature-libs/quote/core/facade/quote.service.ts b/feature-libs/quote/core/facade/quote.service.ts index d9f9f9f9cdf..64f9e1feadf 100644 --- a/feature-libs/quote/core/facade/quote.service.ts +++ b/feature-libs/quote/core/facade/quote.service.ts @@ -89,8 +89,7 @@ export class QuoteService implements QuoteFacade { active: true, }, }); - this.quoteCartService.setQuoteCartActive(true); - this.quoteCartService.setQuoteId(quote.code); + this.eventService.dispatch({}, QuoteDetailsReloadQueryEvent); }), map(([_, _userId, quote]) => quote) @@ -188,10 +187,10 @@ export class QuoteService implements QuoteFacade { ); protected performQuoteActionCommand: Command<{ - quoteCode: string; + quote: Quote; quoteAction: QuoteActionType; }> = this.commandService.create<{ - quoteCode: string; + quote: Quote; quoteAction: QuoteActionType; }>( (payload) => { @@ -199,33 +198,55 @@ export class QuoteService implements QuoteFacade { return this.userIdService.takeUserId().pipe( take(1), switchMap((userId) => - this.quoteConnector.performQuoteAction( - userId, - payload.quoteCode, - payload.quoteAction + zip( + this.quoteConnector.performQuoteAction( + userId, + payload.quote.code, + payload.quoteAction + ), + of(userId) ) ), - tap(() => { + tap(([_result, userId]) => { if ( payload.quoteAction === QuoteActionType.SUBMIT || payload.quoteAction === QuoteActionType.CANCEL ) { - this.quoteCartService.setQuoteCartActive(false); this.cartUtilsService.createNewCartAndGoToQuoteList(); + this.triggerReloadAndCompleteAction(); } if ( payload.quoteAction === QuoteActionType.EDIT || payload.quoteAction === QuoteActionType.CHECKOUT ) { - this.quoteCartService.setQuoteCartActive(true); - this.quoteCartService.setQuoteId(payload.quoteCode); - } - - if (payload.quoteAction === QuoteActionType.CHECKOUT) { - this.quoteCartService.setCheckoutAllowed(true); + //no cartId present: ensure that we re-fetch quote cart id from quote + const cartId = payload.quote.cartId; + if (!cartId) { + this.quoteConnector + .getQuote(userId, payload.quote.code) + .pipe( + filter((quote) => quote.cartId !== undefined), + take(1) + ) + .subscribe((quote) => { + this.loadQuoteCartAndProceed( + userId, + quote.cartId as string, + quote.code, + payload.quoteAction + ); + }); + } else { + this.loadQuoteCartAndProceed( + userId, + cartId, + payload.quote.code, + payload.quoteAction + ); + } + } else { + this.isActionPerforming$.next(false); } - this.isActionPerforming$.next(false); - this.eventService.dispatch({}, QuoteDetailsReloadQueryEvent); }) ); }, @@ -234,6 +255,51 @@ export class QuoteService implements QuoteFacade { } ); + /** + * Loads the quote cart and waits until load is done. Afterwards triggers specific actions depending on the + * action we perform + * @param userId Current user + * @param cartId Quote cart ID + * @param actionType The action we are currently processing + */ + protected loadQuoteCartAndProceed( + userId: string, + cartId: string, + quoteId: string, + actionType: QuoteActionType + ) { + this.multiCartService.loadCart({ + userId: userId, + cartId: cartId, + extraData: { + active: true, + }, + }); + this.activeCartService + .getActive() + .pipe( + filter((cart) => cart.code === cartId), + take(1) + ) + .subscribe(() => { + this.triggerReloadAndCompleteAction(); + if (actionType === QuoteActionType.CHECKOUT) { + this.quoteCartService.setCheckoutAllowed(true); + this.routingService.go({ cxRoute: 'checkout' }); + } else if (actionType === QuoteActionType.REQUOTE) { + this.routingService.go({ + cxRoute: 'quoteDetails', + params: { quoteId: quoteId }, + }); + } + }); + } + + protected triggerReloadAndCompleteAction() { + this.isActionPerforming$.next(false); + this.eventService.dispatch({}, QuoteDetailsReloadQueryEvent); + } + protected requoteCommand: Command<{ quoteStarter: QuoteStarter }, Quote> = this.commandService.create<{ quoteStarter: QuoteStarter }, Quote>( (payload) => { @@ -243,11 +309,12 @@ export class QuoteService implements QuoteFacade { switchMap((userId) => this.quoteConnector.createQuote(userId, payload.quoteStarter).pipe( tap((quote) => { - this.routingService.go({ - cxRoute: 'quoteDetails', - params: { quoteId: quote.code }, - }); - this.isActionPerforming$.next(false); + this.loadQuoteCartAndProceed( + userId, + quote.cartId as string, + quote.code, + QuoteActionType.REQUOTE + ); }) ) ) @@ -261,32 +328,22 @@ export class QuoteService implements QuoteFacade { protected quoteDetailsState$: Query = this.queryService.create( () => - this.routingService.getRouterState().pipe( - //we don't need to cover the intermediate router states where a future route is already known. - //only changes to the URL are relevant. Otherwise we get unneeded hits when e.g. navigating back from quotes - filter((routingData) => routingData.nextState === undefined), - withLatestFrom(this.userIdService.takeUserId()), - switchMap(([{ state }, userId]) => - zip( - this.quoteConnector.getQuote(userId, state.params.quoteId), - this.quoteCartService.isQuoteCartActive(), - this.quoteCartService.getQuoteId(), - of(userId) + //we need to ensure that the active cart has been loaded, in order to determine if the + //quote is connected to a quote cart (and then directly ready for edit) + this.activeCartService.isStable().pipe( + switchMap(() => + this.routingService.getRouterState().pipe( + //we don't need to cover the intermediate router states where a future route is already known. + //only changes to the URL are relevant. Otherwise we get unneeded hits when e.g. navigating back from quotes + filter((routingData) => routingData.nextState === undefined), + withLatestFrom(this.userIdService.takeUserId()), + switchMap(([{ state }, userId]) => + this.quoteConnector.getQuote(userId, state.params.quoteId) + ) ) - ), - tap(([quote, isActive, quoteId, userId]) => { - if (isActive && quote.code === quoteId) { - this.multiCartService.loadCart({ - userId: userId, - cartId: quote.cartId as string, - extraData: { - active: true, - }, - }); - } - }), - map(([quote, _]) => quote) + ) ), + { reloadOn: [QuoteDetailsReloadQueryEvent, LoginEvent], } @@ -369,10 +426,10 @@ export class QuoteService implements QuoteFacade { } performQuoteAction( - quoteCode: string, + quote: Quote, quoteAction: QuoteActionType ): Observable { - return this.performQuoteActionCommand.execute({ quoteCode, quoteAction }); + return this.performQuoteActionCommand.execute({ quote, quoteAction }); } requote(quoteCode: string): Observable { diff --git a/feature-libs/quote/core/services/cart-utils.service.ts b/feature-libs/quote/core/services/cart-utils.service.ts index 1d78318921d..47c4079c932 100644 --- a/feature-libs/quote/core/services/cart-utils.service.ts +++ b/feature-libs/quote/core/services/cart-utils.service.ts @@ -9,7 +9,7 @@ import { Cart, MultiCartFacade } from '@spartacus/cart/base/root'; import { RoutingService, UserIdService } from '@spartacus/core'; import { QuoteCartService } from '@spartacus/quote/root'; import { Observable } from 'rxjs'; -import { tap, take, switchMap } from 'rxjs/operators'; +import { take, switchMap } from 'rxjs/operators'; @Injectable({ providedIn: 'root', @@ -31,10 +31,7 @@ export class CartUtilsService { toMergeCartGuid: undefined, extraData: { active: true }, }) - ), - tap(() => { - this.quoteCartService.setQuoteCartActive(false); - }) + ) ); } diff --git a/feature-libs/quote/root/facade/quote.facade.ts b/feature-libs/quote/root/facade/quote.facade.ts index d0280988847..fe0d65181db 100644 --- a/feature-libs/quote/root/facade/quote.facade.ts +++ b/feature-libs/quote/root/facade/quote.facade.ts @@ -72,7 +72,7 @@ export abstract class QuoteFacade { * Perform action on quote. */ abstract performQuoteAction( - quoteCode: string, + quote: Quote, quoteAction: QuoteActionType ): Observable; diff --git a/feature-libs/quote/root/guards/quote-cart.service.spec.ts b/feature-libs/quote/root/guards/quote-cart.service.spec.ts index c395f1261cd..b86515df3d2 100644 --- a/feature-libs/quote/root/guards/quote-cart.service.spec.ts +++ b/feature-libs/quote/root/guards/quote-cart.service.spec.ts @@ -1,49 +1,74 @@ import { TestBed } from '@angular/core/testing'; import { QuoteCartService } from './quote-cart.service'; -import { QUOTE_CODE } from '../../core/testing/quote-test-utils'; +import { ActiveCartFacade, Cart } from '@spartacus/cart/base/root'; +import createSpy = jasmine.createSpy; +import { of } from 'rxjs'; + +const cartId = '8762'; +const quoteAttachedToCart = '6524'; + +const cart: Cart = { + code: cartId, +}; + +class MockActiveCartFacade implements Partial { + reloadActiveCart = createSpy().and.stub(); + takeActiveCartId = createSpy().and.returnValue(of(cartId)); + requireLoadedCart = createSpy().and.returnValue(of(cart)); + getActive = createSpy().and.returnValue(of(cart)); +} describe('QuoteCartService', () => { let quoteCartService: QuoteCartService; + let activeCartFacade: ActiveCartFacade; beforeEach(() => { TestBed.configureTestingModule({ - providers: [QuoteCartService], + providers: [ + QuoteCartService, + { provide: ActiveCartFacade, useClass: MockActiveCartFacade }, + ], }); quoteCartService = TestBed.inject(QuoteCartService); + activeCartFacade = TestBed.inject(ActiveCartFacade); }); it('should create service', () => { expect(quoteCartService).toBeDefined(); }); - describe('setQuoteId', () => { - it('should trigger emission of new quote id', (done) => { - quoteCartService.setQuoteId(QUOTE_CODE); + describe('setCheckoutActive', () => { + it('should trigger emission of new state', () => { + quoteCartService.setCheckoutAllowed(true); + quoteCartService.isCheckoutAllowed().subscribe((isAllowed) => { + expect(isAllowed).toBe(true); + }); + }); + }); + describe('getQuoteId', () => { + it('should request activeCartFacade to find quote id', () => { + cart.quoteCode = quoteAttachedToCart; quoteCartService.getQuoteId().subscribe((quoteId) => { - expect(quoteId).toBe(QUOTE_CODE); - done(); + expect(activeCartFacade.getActive).toHaveBeenCalled(); + expect(quoteId).toEqual(quoteAttachedToCart); }); }); }); - describe('setQuoteCartActive', () => { - it('should trigger emission of new state', (done) => { - quoteCartService.setQuoteCartActive(true); + describe('isQuoteCartActive', () => { + it('should request activeCartFacade to find quote id and determine if a link exists', () => { + cart.quoteCode = quoteAttachedToCart; quoteCartService.isQuoteCartActive().subscribe((isActive) => { + expect(activeCartFacade.getActive).toHaveBeenCalled(); expect(isActive).toBe(true); - done(); }); }); - }); - - describe('setCheckoutActive', () => { - it('should trigger emission of new state', (done) => { - quoteCartService.setCheckoutAllowed(true); - quoteCartService.isCheckoutAllowed().subscribe((isAllowed) => { - expect(isAllowed).toBe(true); - done(); + it('should return false in case cart is not linked to any quote', () => { + cart.quoteCode = undefined; + quoteCartService.isQuoteCartActive().subscribe((isActive) => { + expect(isActive).toBe(false); }); }); }); diff --git a/feature-libs/quote/root/guards/quote-cart.service.ts b/feature-libs/quote/root/guards/quote-cart.service.ts index fbaeca84d56..a1870a0703d 100644 --- a/feature-libs/quote/root/guards/quote-cart.service.ts +++ b/feature-libs/quote/root/guards/quote-cart.service.ts @@ -5,42 +5,31 @@ */ import { Injectable } from '@angular/core'; +import { ActiveCartFacade } from '@spartacus/cart/base/root'; import { Observable, ReplaySubject } from 'rxjs'; +import { map } from 'rxjs/operators'; @Injectable({ providedIn: 'root', }) -//TODO in the course of https://jira.tools.sap/browse/CXSPA-4208: -//Either remove this service or have it populated by cart calls export class QuoteCartService { - private quoteId = new ReplaySubject(1); - private quoteCartActive = new ReplaySubject(1); private checkoutAllowed = new ReplaySubject(1); - - private quoteIdAsObservable = this.quoteId.asObservable(); - private quoteCartActiveAsObservable = this.quoteCartActive.asObservable(); private checkoutAllowedAsObservable = this.checkoutAllowed.asObservable(); - constructor() { - this.quoteCartActive.next(false); + constructor(protected activeCartFacade: ActiveCartFacade) { this.checkoutAllowed.next(false); - this.quoteId.next(''); - } - - public setQuoteId(quoteId: string): void { - this.quoteId.next(quoteId); - } - - public getQuoteId(): Observable { - return this.quoteIdAsObservable; } - public setQuoteCartActive(quoteCartActive: boolean): void { - this.quoteCartActive.next(quoteCartActive); + public getQuoteId(): Observable { + return this.activeCartFacade + .getActive() + .pipe(map((cart) => cart.quoteCode)); } public isQuoteCartActive(): Observable { - return this.quoteCartActiveAsObservable; + return this.activeCartFacade + .getActive() + .pipe(map((cart) => cart.quoteCode !== undefined)); } public setCheckoutAllowed(checkoutAllowed: boolean): void { diff --git a/projects/core/src/occ/occ-models/occ.models.ts b/projects/core/src/occ/occ-models/occ.models.ts index 9a79284db17..52684ef70c5 100644 --- a/projects/core/src/occ/occ-models/occ.models.ts +++ b/projects/core/src/occ/occ-models/occ.models.ts @@ -1107,6 +1107,16 @@ export namespace Occ { totalUnitCount?: number; user?: Principal; + + sapQuote?: SapQuote; + } + + /** + * + * An interface representing the quote the cart is liked to. + */ + export interface SapQuote { + code: string; } /**