diff --git a/feature-libs/quote/assets/translations/en/quote.i18n.ts b/feature-libs/quote/assets/translations/en/quote.i18n.ts index 925e85ca086..17cce258d55 100644 --- a/feature-libs/quote/assets/translations/en/quote.i18n.ts +++ b/feature-libs/quote/assets/translations/en/quote.i18n.ts @@ -53,6 +53,7 @@ export const quote = { created: 'Created', lastUpdated: 'Last Updated', estimatedTotal: 'Estimated Total', + total: 'Total', description: 'Description', expiryDate: 'Expiry Date', }, diff --git a/feature-libs/quote/components/details/cart/quote-details-cart.component.html b/feature-libs/quote/components/details/cart/quote-details-cart.component.html index 9f2ca83b0fe..55fa634922a 100644 --- a/feature-libs/quote/components/details/cart/quote-details-cart.component.html +++ b/feature-libs/quote/components/details/cart/quote-details-cart.component.html @@ -7,11 +7,11 @@ {{ 'quote.commons.cart' | cxTranslate }} - + diff --git a/feature-libs/quote/components/details/cart/quote-details-cart.component.spec.ts b/feature-libs/quote/components/details/cart/quote-details-cart.component.spec.ts index 90e79fbc67e..aaaee1dcf71 100644 --- a/feature-libs/quote/components/details/cart/quote-details-cart.component.spec.ts +++ b/feature-libs/quote/components/details/cart/quote-details-cart.component.spec.ts @@ -1,9 +1,23 @@ import { TestBed } from '@angular/core/testing'; import { QuoteDetailsCartComponent } from './quote-details-cart.component'; -import { QuoteFacade } from '@spartacus/quote/root'; -import { MockQuoteFacade } from '../overview/quote-details-overview.component.spec'; +import { Quote, QuoteFacade } from '@spartacus/quote/root'; + import { I18nTestingModule } from '@spartacus/core'; import { IconTestingModule } from '@spartacus/storefront'; +import { Observable, of } from 'rxjs'; +import { + QUOTE_CODE, + createEmptyQuote, +} from '../../../core/testing/quote-test-utils'; +import { By } from '@angular/platform-browser'; + +const quote: Quote = createEmptyQuote(); + +class MockQuoteFacade implements Partial { + getQuoteDetails(): Observable { + return of(quote); + } +} describe('QuoteDetailsCartComponent', () => { beforeEach(() => { @@ -24,4 +38,34 @@ describe('QuoteDetailsCartComponent', () => { const component = fixture.componentInstance; expect(component).toBeTruthy(); }); + + it('should per default display CARET_UP', () => { + const fixture = TestBed.createComponent(QuoteDetailsCartComponent); + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement.textContent).toContain( + 'CARET_UP' + ); + }); + + it('should toggle caret when clicked', () => { + const fixture = TestBed.createComponent(QuoteDetailsCartComponent); + fixture.detectChanges(); + const caret = fixture.debugElement.query( + By.css('.cart-toggle') + ).nativeElement; + caret.click(); + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement.textContent).toContain( + 'CARET_DOWN' + ); + }); + + it('should provide quote details observable', (done) => { + const fixture = TestBed.createComponent(QuoteDetailsCartComponent); + const component = fixture.componentInstance; + component.quoteDetails$.subscribe((quoteDetails) => { + expect(quoteDetails.code).toBe(QUOTE_CODE); + done(); + }); + }); }); diff --git a/feature-libs/quote/components/details/cart/quote-details-cart.component.ts b/feature-libs/quote/components/details/cart/quote-details-cart.component.ts index c9171013eba..6b941486dad 100644 --- a/feature-libs/quote/components/details/cart/quote-details-cart.component.ts +++ b/feature-libs/quote/components/details/cart/quote-details-cart.component.ts @@ -6,17 +6,18 @@ import { Component } from '@angular/core'; import { CartOutlets } from '@spartacus/cart/base/root'; -import { QuoteFacade } from '@spartacus/quote/root'; +import { Quote, QuoteFacade } from '@spartacus/quote/root'; import { ICON_TYPE } from '@spartacus/storefront'; +import { Observable } from 'rxjs'; @Component({ selector: 'cx-quote-details-cart', templateUrl: './quote-details-cart.component.html', }) export class QuoteDetailsCartComponent { - quoteDetails$ = this.quoteFacade.getQuoteDetails(); + quoteDetails$: Observable = this.quoteFacade.getQuoteDetails(); iconTypes = ICON_TYPE; - showCart = true; + showCart: boolean = true; readonly cartOutlets = CartOutlets; constructor(protected quoteFacade: QuoteFacade) {} diff --git a/feature-libs/quote/components/details/cart/quote-details-cart.module.ts b/feature-libs/quote/components/details/cart/quote-details-cart.module.ts index 94c7d97372e..3ac1a48116c 100644 --- a/feature-libs/quote/components/details/cart/quote-details-cart.module.ts +++ b/feature-libs/quote/components/details/cart/quote-details-cart.module.ts @@ -16,6 +16,12 @@ import { IconModule, OutletModule } from '@spartacus/storefront'; import { QuoteDetailsCartComponent } from './quote-details-cart.component'; import { QuoteDetailsCartSummaryComponent } from './summary/quote-details-cart-summary.component'; +//https://jira.tools.sap/browse/CXSPA-4039 + +//CartBaseComponentsModule import in order to ensure that the cart outlet implementations are +//loaded once this component is displayed. Still after one interaction, outlet displays twice + +//Side note: importing CartBaseModule will lead to a duplicate rendering of the cart item list outlet @NgModule({ imports: [CommonModule, OutletModule, IconModule, I18nModule], providers: [ diff --git a/feature-libs/quote/components/details/cart/summary/quote-details-cart-summary.component.ts b/feature-libs/quote/components/details/cart/summary/quote-details-cart-summary.component.ts index 44a12955567..d82d7d7db33 100644 --- a/feature-libs/quote/components/details/cart/summary/quote-details-cart-summary.component.ts +++ b/feature-libs/quote/components/details/cart/summary/quote-details-cart-summary.component.ts @@ -13,7 +13,7 @@ import { QuoteFacade } from '@spartacus/quote/root'; templateUrl: 'quote-details-cart-summary.component.html', }) export class QuoteDetailsCartSummaryComponent { - quoteDetails$ = this.quoteFacade.getQuoteDetails(); + quoteDetails$ = this.quoteFacade.getQuoteDetailsQueryState(); readonly cartOutlets = CartOutlets; diff --git a/feature-libs/quote/components/details/overview/quote-details-overview.component.html b/feature-libs/quote/components/details/overview/quote-details-overview.component.html index 6241ec10676..4c06db184ff 100644 --- a/feature-libs/quote/components/details/overview/quote-details-overview.component.html +++ b/feature-libs/quote/components/details/overview/quote-details-overview.component.html @@ -1,90 +1,84 @@ - - -
-

- {{ 'quote.commons.id' | cxTranslate }}: - {{ quoteDetailsState?.data?.code }} -

-

- {{ 'quote.commons.status' | cxTranslate }} - {{ 'quote.states.' + quoteDetailsState?.data?.state | cxTranslate }} -

-
-
-
-
- - - -
-
- - -
-
- - -
+ +
+

+ {{ 'quote.commons.id' | cxTranslate }}: + {{ quoteDetails.code }} +

+

+ {{ 'quote.commons.status' | cxTranslate }} + {{ 'quote.states.' + quoteDetails.state | cxTranslate }} +

+
+
+
+
+ + + +
+
+ + +
+
+ +
- - - -
- -
-
+
+ + +
+ +
+
diff --git a/feature-libs/quote/components/details/overview/quote-details-overview.component.spec.ts b/feature-libs/quote/components/details/overview/quote-details-overview.component.spec.ts index c9a5bbe6b2f..7b4fbf22338 100644 --- a/feature-libs/quote/components/details/overview/quote-details-overview.component.spec.ts +++ b/feature-libs/quote/components/details/overview/quote-details-overview.component.spec.ts @@ -8,17 +8,15 @@ import { QuoteActionType, QuoteState, } from '@spartacus/quote/root'; -import { - I18nTestingModule, - QueryState, - TranslationService, -} from '@spartacus/core'; +import { I18nTestingModule, TranslationService } from '@spartacus/core'; import { CardModule } from '@spartacus/storefront'; import { Observable, of } from 'rxjs'; import { QuoteDetailsOverviewComponent } from './quote-details-overview.component'; import createSpy = jasmine.createSpy; +const totalPriceFormattedValue = '$20'; + const mockCartId = '1234'; const mockAction = { type: QuoteActionType.CREATE, isPrimary: true }; const mockQuote: Quote = { @@ -32,17 +30,17 @@ const mockQuote: Quote = { updatedTime: new Date('2022-06-09T13:31:36+0000'), previousEstimatedTotal: { currencyIso: 'USD', - formattedValue: '$0.00', - value: 0, + formattedValue: '$1.00', + value: 1, }, state: QuoteState.BUYER_ORDERED, name: 'Name', - totalPrice: { value: 20 }, + totalPrice: { value: 20, formattedValue: totalPriceFormattedValue }, }; export class MockQuoteFacade implements Partial { - getQuoteDetails(): Observable> { - return of({ data: mockQuote, loading: false, error: false }); + getQuoteDetails(): Observable { + return of(mockQuote); } setSort = createSpy(); setCurrentPage = createSpy(); @@ -143,4 +141,36 @@ describe('QuoteDetailsOverviewComponent', () => { expect(result).toEqual(expected); }); }); + + describe('getTotalPrice', () => { + it('should return the total price formatted value in case it is available', () => { + expect(component.getTotalPrice(mockQuote)).toBe(totalPriceFormattedValue); + }); + + it('should return null in case no formatted value is available', () => { + const quoteWOPrices: Quote = { + ...mockQuote, + totalPrice: {}, + }; + expect(component.getTotalPrice(quoteWOPrices)).toBe(null); + }); + }); + + describe('getTotalPriceDescription', () => { + it('should name total price as estimated as long as final status not reached', () => { + expect(component.getTotalPriceDescription(mockQuote)).toBe( + 'quote.details.estimatedTotal' + ); + }); + + it('should name total price as total as in case final status reached, i.e. checkout action is available', () => { + const quoteInOfferState: Quote = { + ...mockQuote, + allowedActions: [{ type: QuoteActionType.CHECKOUT, isPrimary: true }], + }; + expect(component.getTotalPriceDescription(quoteInOfferState)).toBe( + 'quote.details.total' + ); + }); + }); }); diff --git a/feature-libs/quote/components/details/overview/quote-details-overview.component.ts b/feature-libs/quote/components/details/overview/quote-details-overview.component.ts index a78ec1ebe5a..7cbde9be137 100644 --- a/feature-libs/quote/components/details/overview/quote-details-overview.component.ts +++ b/feature-libs/quote/components/details/overview/quote-details-overview.component.ts @@ -5,7 +5,7 @@ */ import { Component } from '@angular/core'; -import { QuoteFacade } from '@spartacus/quote/root'; +import { Quote, QuoteActionType, QuoteFacade } from '@spartacus/quote/root'; import { TranslationService } from '@spartacus/core'; import { Card } from '@spartacus/storefront'; import { Observable } from 'rxjs'; @@ -16,7 +16,7 @@ import { map } from 'rxjs/operators'; templateUrl: './quote-details-overview.component.html', }) export class QuoteDetailsOverviewComponent { - quoteDetails$ = this.quoteFacade.getQuoteDetails(); + quoteDetails$: Observable = this.quoteFacade.getQuoteDetails(); constructor( protected quoteFacade: QuoteFacade, @@ -32,4 +32,26 @@ export class QuoteDetailsOverviewComponent { })) ); } + /** + * Returns total price as formatted string + * @param quote Quote + * @returns Total price formatted format, null if that is not available + */ + getTotalPrice(quote: Quote): string | null { + return quote.totalPrice.formattedValue ?? null; + } + + /** + * Returns total price description + * @param quote Quote + * @returns 'Total' price if quote is in final state, 'Estimated total' otherwise + */ + getTotalPriceDescription(quote: Quote): string { + const readyToSubmit = quote.allowedActions.find( + (action) => action.type === QuoteActionType.CHECKOUT + ); + return readyToSubmit + ? 'quote.details.total' + : 'quote.details.estimatedTotal'; + } } diff --git a/feature-libs/quote/components/details/vendor-contact/quote-details-vendor-contact.component.ts b/feature-libs/quote/components/details/vendor-contact/quote-details-vendor-contact.component.ts index 5ddd8137253..43d138dbe54 100644 --- a/feature-libs/quote/components/details/vendor-contact/quote-details-vendor-contact.component.ts +++ b/feature-libs/quote/components/details/vendor-contact/quote-details-vendor-contact.component.ts @@ -14,7 +14,7 @@ import { ICON_TYPE, MessagingConfigs } from '@spartacus/storefront'; templateUrl: './quote-details-vendor-contact.component.html', }) export class QuoteDetailsVendorContactComponent { - quoteDetails$ = this.quoteFacade.getQuoteDetails(); + quoteDetails$ = this.quoteFacade.getQuoteDetailsQueryState(); showVendorContact = true; iconTypes = ICON_TYPE; vendorplaceHolder: string = 'Vendor Contact Component'; 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 320d0d6e390..55df53fdd87 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 @@ -10,7 +10,6 @@ import { GlobalMessageService, I18nTestingModule, Price, - QueryState, TranslationService, } from '@spartacus/core'; @@ -39,15 +38,8 @@ const mockQuote: Quote = { threshold: threshold, totalPrice: totalPrice, }; -const mockQuoteDetailsState: QueryState = { - loading: false, - error: false, - data: mockQuote, -}; -const mockQuoteDetailsState$ = new BehaviorSubject>( - mockQuoteDetailsState -); +const mockQuoteDetails$ = new BehaviorSubject(mockQuote); const dialogClose$ = new BehaviorSubject(undefined); class MockLaunchDialogService implements Partial { @@ -68,8 +60,8 @@ class MockLaunchDialogService implements Partial { } class MockCommerceQuotesFacade implements Partial { - getQuoteDetails(): Observable> { - return mockQuoteDetailsState$.asObservable(); + getQuoteDetails(): Observable { + return mockQuoteDetails$.asObservable(); } performQuoteAction( _quoteCode: string, @@ -119,7 +111,7 @@ describe('QuoteActionsByRoleComponent', () => { launchDialogService = TestBed.inject(LaunchDialogService); facade = TestBed.inject(QuoteFacade); globalMessageService = TestBed.inject(GlobalMessageService); - mockQuoteDetailsState$.next(mockQuoteDetailsState); + mockQuoteDetails$.next(mockQuote); }); it('should create component', () => { @@ -129,7 +121,7 @@ describe('QuoteActionsByRoleComponent', () => { it('should read quote details state', (done) => { component.quoteDetails$.pipe(take(1)).subscribe((state) => { - expect(state).toEqual(mockQuoteDetailsState.data); + expect(state).toEqual(mockQuote); done(); }); }); @@ -193,28 +185,24 @@ describe('QuoteActionsByRoleComponent', () => { it('should open confirmation dialog when action is SUBMIT', () => { spyOn(launchDialogService, 'openDialog'); - const newMockQuoteWithSubmitAction: QueryState = { - error: false, - loading: false, - data: { - ...mockQuote, - allowedActions: [ - { type: QuoteActionType.SUBMIT, isPrimary: true }, - { type: QuoteActionType.CANCEL, isPrimary: false }, - ], - }, + const newMockQuoteWithSubmitAction: Quote = { + ...mockQuote, + allowedActions: [ + { type: QuoteActionType.SUBMIT, isPrimary: true }, + { type: QuoteActionType.CANCEL, isPrimary: false }, + ], }; - mockQuoteDetailsState$.next(newMockQuoteWithSubmitAction); + mockQuoteDetails$.next(newMockQuoteWithSubmitAction); fixture.detectChanges(); component.onClick( QuoteActionType.SUBMIT, - newMockQuoteWithSubmitAction.data?.code ?? '' + newMockQuoteWithSubmitAction.code ); expect(launchDialogService.openDialog).toHaveBeenCalledWith( LAUNCH_CALLER.REQUEST_CONFIRMATION, component.element, component['viewContainerRef'], - { quoteCode: newMockQuoteWithSubmitAction.data?.code } + { quoteCode: newMockQuoteWithSubmitAction.code } ); }); @@ -227,24 +215,17 @@ describe('QuoteActionsByRoleComponent', () => { totalPrice: { value: threshold - 1 }, allowedActions: allowedActionsSubmit, }; - const queryStateSubmittableQuote: QueryState = { - error: false, - loading: false, - data: { - ...mockQuote, - allowedActions: allowedActionsSubmit, - }, + const submittableQuote: Quote = { + ...mockQuote, + allowedActions: allowedActionsSubmit, }; - const queryStateCancellableQuote: QueryState = { - ...queryStateSubmittableQuote, - data: { - ...quoteFailingThreshold, - allowedActions: [{ type: QuoteActionType.CANCEL, isPrimary: true }], - }, + const cancellableQuote: Quote = { + ...quoteFailingThreshold, + allowedActions: [{ type: QuoteActionType.CANCEL, isPrimary: true }], }; it('should let submit button enabled if threshold is met', () => { - mockQuoteDetailsState$.next(queryStateSubmittableQuote); + mockQuoteDetails$.next(submittableQuote); fixture.detectChanges(); const actionButtons = fixture.debugElement.queryAll(By.css('.btn')); expect(actionButtons).toBeDefined(); @@ -253,7 +234,7 @@ describe('QuoteActionsByRoleComponent', () => { it('should let submit button enabled if threshold is not specified', () => { mockQuote.threshold = undefined; - mockQuoteDetailsState$.next(queryStateSubmittableQuote); + mockQuoteDetails$.next(submittableQuote); fixture.detectChanges(); const actionButtons = fixture.debugElement.queryAll(By.css('.btn')); expect(actionButtons).toBeDefined(); @@ -262,14 +243,7 @@ describe('QuoteActionsByRoleComponent', () => { it('should disable submit button if threshold is not met and raise message', () => { spyOn(globalMessageService, 'add').and.callThrough(); - - const queryStateSubmittableQuoteFailingThreshold: QueryState = { - ...queryStateSubmittableQuote, - data: { - ...quoteFailingThreshold, - }, - }; - mockQuoteDetailsState$.next(queryStateSubmittableQuoteFailingThreshold); + mockQuoteDetails$.next(quoteFailingThreshold); fixture.detectChanges(); const actionButtons = fixture.debugElement.queryAll(By.css('.btn')); @@ -281,13 +255,7 @@ describe('QuoteActionsByRoleComponent', () => { it('should disable submit button if total price value is not provided', () => { quoteFailingThreshold.totalPrice.value = undefined; - const queryStateSubmittableQuoteFailingThreshold: QueryState = { - ...queryStateSubmittableQuote, - data: { - ...quoteFailingThreshold, - }, - }; - mockQuoteDetailsState$.next(queryStateSubmittableQuoteFailingThreshold); + mockQuoteDetails$.next(quoteFailingThreshold); fixture.detectChanges(); const actionButtons = fixture.debugElement.queryAll(By.css('.btn')); @@ -296,7 +264,7 @@ describe('QuoteActionsByRoleComponent', () => { }); it('should not touch buttons other than submit', () => { - mockQuoteDetailsState$.next(queryStateCancellableQuote); + mockQuoteDetails$.next(cancellableQuote); fixture.detectChanges(); const actionButtons = fixture.debugElement.queryAll(By.css('.btn')); @@ -306,7 +274,7 @@ describe('QuoteActionsByRoleComponent', () => { it('should not raise message in case threshold not met and submit action not present', () => { spyOn(globalMessageService, 'add').and.callThrough(); - mockQuoteDetailsState$.next(queryStateCancellableQuote); + mockQuoteDetails$.next(cancellableQuote); fixture.detectChanges(); expect(globalMessageService.add).toHaveBeenCalledTimes(0); @@ -315,27 +283,23 @@ describe('QuoteActionsByRoleComponent', () => { it('should perform quote action when action is SUBMIT and confirm dialogClose reason is yes', () => { spyOn(facade, 'performQuoteAction').and.callThrough(); - const newMockQuoteWithSubmitAction: QueryState = { - error: false, - loading: false, - data: { - ...mockQuote, - allowedActions: [ - { type: QuoteActionType.SUBMIT, isPrimary: true }, - { type: QuoteActionType.CANCEL, isPrimary: false }, - ], - }, + const newMockQuoteWithSubmitAction: Quote = { + ...mockQuote, + allowedActions: [ + { type: QuoteActionType.SUBMIT, isPrimary: true }, + { type: QuoteActionType.CANCEL, isPrimary: false }, + ], }; - mockQuoteDetailsState$.next(newMockQuoteWithSubmitAction); + mockQuoteDetails$.next(newMockQuoteWithSubmitAction); fixture.detectChanges(); component.onClick( QuoteActionType.SUBMIT, - newMockQuoteWithSubmitAction.data?.code ?? '' + newMockQuoteWithSubmitAction.code ); launchDialogService.closeDialog('yes'); expect(facade.performQuoteAction).toHaveBeenCalledWith( - newMockQuoteWithSubmitAction.data?.code, + newMockQuoteWithSubmitAction.code, QuoteActionType.SUBMIT ); }); 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 b9c033dfcd3..79ddee32680 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 @@ -16,19 +16,14 @@ import { GlobalMessageService, GlobalMessageType } from '@spartacus/core'; import { QuoteFacade, QuoteActionType, Quote } from '@spartacus/quote/root'; import { LAUNCH_CALLER, LaunchDialogService } from '@spartacus/storefront'; import { Observable, Subscription } from 'rxjs'; -import { filter, map, take, tap } from 'rxjs/operators'; +import { filter, take, tap } from 'rxjs/operators'; @Component({ selector: 'cx-quote-actions-by-role', templateUrl: './quote-actions-by-role.component.html', }) export class QuoteActionsByRoleComponent implements OnInit, OnDestroy { - quoteDetails$: Observable = this.quoteFacade.getQuoteDetails().pipe( - filter((state) => !state.loading), - filter((state) => state.data !== undefined), - map((state) => state.data), - map((quote) => quote as Quote) - ); + quoteDetails$: Observable = this.quoteFacade.getQuoteDetails(); @ViewChild('element') element: ElementRef; diff --git a/feature-libs/quote/core/connectors/converters.ts b/feature-libs/quote/core/connectors/converters.ts index 34383b9a1b4..14e9a43555a 100644 --- a/feature-libs/quote/core/connectors/converters.ts +++ b/feature-libs/quote/core/connectors/converters.ts @@ -17,14 +17,14 @@ import { QuoteStarter, } from '@spartacus/quote/root'; -export const QUOTE_LIST_NORMALIZER = new InjectionToken< - Converter ->('QuoteListNormalizer'); - export const QUOTE_NORMALIZER = new InjectionToken>( 'QuoteNormalizer' ); +export const QUOTE_LIST_NORMALIZER = new InjectionToken< + Converter +>('QuoteListNormalizer'); + export const QUOTE_STARTER_SERIALIZER = new InjectionToken< Converter >('QuoteStarterSerializer'); diff --git a/feature-libs/quote/core/facade/quote.service.spec.ts b/feature-libs/quote/core/facade/quote.service.spec.ts index 7062b237bd0..3ae52a4fa92 100644 --- a/feature-libs/quote/core/facade/quote.service.spec.ts +++ b/feature-libs/quote/core/facade/quote.service.spec.ts @@ -5,6 +5,7 @@ import { Comment, Quote, QuoteActionType, + QuoteDetailsReloadQueryEvent, QuoteList, QuoteMetadata, QuotesStateParams, @@ -187,6 +188,32 @@ describe('QuoteService', () => { }); }); + it('should signal that quote details need to be re-read when performing search', () => { + service + .getQuotesState(mockQuotesStateParams) + .pipe(take(1)) + .subscribe(() => { + expect(eventService.dispatch).toHaveBeenCalledWith( + {}, + QuoteDetailsReloadQueryEvent + ); + }); + }); + + it('should return quote details query state after calling quoteConnector.getQuote', () => { + service + .getQuoteDetailsQueryState() + .pipe(take(1)) + .subscribe((details) => { + expect(connector.getQuote).toHaveBeenCalledWith( + mockUserId, + mockParams.quoteId + ); + expect(details.data).toEqual(mockQuote); + expect(details.loading).toBe(false); + }); + }); + it('should return quote details after calling quoteConnector.getQuote', () => { service .getQuoteDetails() @@ -196,7 +223,7 @@ describe('QuoteService', () => { mockUserId, mockParams.quoteId ); - expect(details.data).toEqual(mockQuote); + expect(details).toEqual(mockQuote); }); }); diff --git a/feature-libs/quote/core/facade/quote.service.ts b/feature-libs/quote/core/facade/quote.service.ts index 6b9d745593a..c35cfa85343 100644 --- a/feature-libs/quote/core/facade/quote.service.ts +++ b/feature-libs/quote/core/facade/quote.service.ts @@ -36,6 +36,7 @@ import { BehaviorSubject, combineLatest, Observable, of, zip } from 'rxjs'; import { concatMap, distinctUntilChanged, + filter, map, switchMap, take, @@ -228,6 +229,9 @@ export class QuoteService implements QuoteFacade { sort, pageSize: this.config.view?.defaultPageSize, }); + }), + tap(() => { + this.eventService.dispatch({}, QuoteDetailsReloadQueryEvent); }) ), { @@ -292,7 +296,7 @@ export class QuoteService implements QuoteFacade { return this.getQuotesStateQuery(params).getState(); } - getQuoteDetails(): Observable> { + getQuoteDetailsQueryState(): Observable> { return combineLatest([ this.isActionPerforming$, this.quoteDetailsState$.getState(), @@ -303,4 +307,13 @@ export class QuoteService implements QuoteFacade { })) ); } + + getQuoteDetails(): Observable { + return this.getQuoteDetailsQueryState().pipe( + filter((state) => !state.loading), + filter((state) => state.data !== undefined), + map((state) => state.data), + map((quote) => quote as Quote) + ); + } } diff --git a/feature-libs/quote/core/testing/quote-test-utils.ts b/feature-libs/quote/core/testing/quote-test-utils.ts index d7ce08ad7c3..20402d79453 100644 --- a/feature-libs/quote/core/testing/quote-test-utils.ts +++ b/feature-libs/quote/core/testing/quote-test-utils.ts @@ -9,10 +9,10 @@ import { Quote } from '@spartacus/quote/root'; /** * Quote test utils */ - +export const QUOTE_CODE = '00010000'; export function createEmptyQuote(): Quote { return { - code: '00010000', + code: QUOTE_CODE, name: 'Quote', allowedActions: [], totalPrice: {}, diff --git a/feature-libs/quote/occ/adapters/occ-quote.adapter.ts b/feature-libs/quote/occ/adapters/occ-quote.adapter.ts index 7d6f5ea7c0a..34e19e3111f 100644 --- a/feature-libs/quote/occ/adapters/occ-quote.adapter.ts +++ b/feature-libs/quote/occ/adapters/occ-quote.adapter.ts @@ -13,8 +13,8 @@ import { QUOTE_DISCOUNT_SERIALIZER, QUOTE_LIST_NORMALIZER, QUOTE_METADATA_SERIALIZER, - QUOTE_NORMALIZER, QUOTE_STARTER_SERIALIZER, + QUOTE_NORMALIZER, } from '@spartacus/quote/core'; import { Comment, diff --git a/feature-libs/quote/occ/config/default-occ-quote-config.ts b/feature-libs/quote/occ/config/default-occ-quote-config.ts index e1684ecea26..7fc21b6e84a 100644 --- a/feature-libs/quote/occ/config/default-occ-quote-config.ts +++ b/feature-libs/quote/occ/config/default-occ-quote-config.ts @@ -13,7 +13,7 @@ export const defaultOccQuoteConfig: OccConfig = { getQuotes: 'users/${userId}/quotes', createQuote: 'users/${userId}/quotes', getQuote: - 'users/${userId}/quotes/${quoteCode}?fields=FULL,previousEstimatedTotal(formattedValue)', + 'users/${userId}/quotes/${quoteCode}?fields=FULL,previousEstimatedTotal(formattedValue),totalPrice(formattedValue),entries(FULL)', editQuote: 'users/${userId}/quotes/${quoteCode}', performQuoteAction: 'users/${userId}/quotes/${quoteCode}/action', addComment: 'users/${userId}/quotes/${quoteCode}/comments', diff --git a/feature-libs/quote/occ/converters/occ-quote-entry-normalizer.spec.ts b/feature-libs/quote/occ/converters/occ-quote-entry-normalizer.spec.ts new file mode 100644 index 00000000000..421eb813297 --- /dev/null +++ b/feature-libs/quote/occ/converters/occ-quote-entry-normalizer.spec.ts @@ -0,0 +1,47 @@ +import { TestBed } from '@angular/core/testing'; +import { ConverterService, PRODUCT_NORMALIZER } from '@spartacus/core'; +import { OccQuoteEntryNormalizer } from './occ-quote-entry-normalizer'; + +class MockConverterService { + convert() {} +} + +describe('OccQuoteEntryNormalizer', () => { + let occQuoteEntryNormalizer: OccQuoteEntryNormalizer; + let converter: ConverterService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OccQuoteEntryNormalizer, + { provide: ConverterService, useClass: MockConverterService }, + ], + }); + + occQuoteEntryNormalizer = TestBed.inject(OccQuoteEntryNormalizer); + converter = TestBed.inject(ConverterService); + spyOn(converter, 'convert').and.callThrough(); + }); + + it('should be created', () => { + expect(occQuoteEntryNormalizer).toBeTruthy(); + }); + + it('should convert quote entries', () => { + const product = { code: 'testproductcode 1' }; + const price = { value: 123 }; + const quote = { + allowedActions: [], + code: 'testquote', + comments: [], + description: 'test description', + entries: [{ product }], + name: 'test name', + totalPrice: price, + }; + + const result = occQuoteEntryNormalizer.convert(quote); + expect(result.code).toBe(quote.code); + expect(converter.convert).toHaveBeenCalledWith(product, PRODUCT_NORMALIZER); + }); +}); diff --git a/feature-libs/quote/occ/converters/occ-quote-entry-normalizer.ts b/feature-libs/quote/occ/converters/occ-quote-entry-normalizer.ts new file mode 100644 index 00000000000..38c7aab1326 --- /dev/null +++ b/feature-libs/quote/occ/converters/occ-quote-entry-normalizer.ts @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { OccQuote, Quote } from '@spartacus/quote/root'; +import { + Converter, + ConverterService, + PRODUCT_NORMALIZER, +} from '@spartacus/core'; + +@Injectable({ providedIn: 'root' }) +export class OccQuoteEntryNormalizer implements Converter { + constructor(private converter: ConverterService) {} + + convert(source: OccQuote, target?: Quote): Quote { + if (!target) { + target = { ...(source as any) } as Quote; + } + + if (source.entries) { + target.entries = source.entries.map((entry) => ({ + ...entry, + product: this.converter.convert(entry.product, PRODUCT_NORMALIZER), + })); + } + return target; + } +} diff --git a/feature-libs/quote/occ/quote-occ.module.ts b/feature-libs/quote/occ/quote-occ.module.ts index 6ac4d0781fc..0634db3238a 100644 --- a/feature-libs/quote/occ/quote-occ.module.ts +++ b/feature-libs/quote/occ/quote-occ.module.ts @@ -12,6 +12,7 @@ import { QuoteAdapter, QUOTE_NORMALIZER } from '@spartacus/quote/core'; import { OccQuoteAdapter } from './adapters/occ-quote.adapter'; import { defaultOccQuoteConfig } from './config/default-occ-quote-config'; import { OccQuoteActionNormalizer } from './converters/occ-quote-action-normalizer'; +import { OccQuoteEntryNormalizer } from './converters/occ-quote-entry-normalizer'; @NgModule({ imports: [CommonModule], @@ -26,6 +27,11 @@ import { OccQuoteActionNormalizer } from './converters/occ-quote-action-normaliz useExisting: OccQuoteActionNormalizer, multi: true, }, + { + provide: QUOTE_NORMALIZER, + useExisting: OccQuoteEntryNormalizer, + multi: true, + }, ], }) export class QuoteOccModule {} diff --git a/feature-libs/quote/root/facade/quote.facade.ts b/feature-libs/quote/root/facade/quote.facade.ts index b25f9cca424..050328b8b17 100644 --- a/feature-libs/quote/root/facade/quote.facade.ts +++ b/feature-libs/quote/root/facade/quote.facade.ts @@ -25,6 +25,7 @@ import { feature: QUOTE_FEATURE, methods: [ 'getQuotesState', + 'getQuoteDetailsQueryState', 'getQuoteDetails', 'createQuote', 'editQuote', @@ -80,7 +81,14 @@ export abstract class QuoteFacade { abstract requote(quoteCode: string): Observable; /** - * Returns the quote details. + * Returns the quote details query state. */ - abstract getQuoteDetails(): Observable>; + abstract getQuoteDetailsQueryState(): Observable< + QueryState + >; + + /** + * Returns the quote details once it has been fully loaded. + */ + abstract getQuoteDetails(): Observable; }