From 96e51c761e8df3d078f4924d7408a06d9f99b1a8 Mon Sep 17 00:00:00 2001 From: anjana-bl <84384970+anjana-bl@users.noreply.github.com> Date: Tue, 10 Sep 2024 21:05:57 +0530 Subject: [PATCH] feat/CXSPA-8061: S/4HANA Service Order UI - Alter Delivery Mode Tab in Checkout (#19176) Co-authored-by: github-actions[bot] --- .env-cmdrc | 2 +- .../order/components/order-details/index.ts | 1 + .../order-details/order-details.module.ts | 2 + .../order-orverview-component.service.spec.ts | 28 ++ .../order-overview-component.service.ts | 15 ++ .../order-overview.component.html | 2 +- .../order-overview.component.spec.ts | 31 +++ .../order-overview.component.ts | 9 +- .../assets/translations/en/s4-service.json | 6 +- ...vice-checkout-delivery-mode.component.html | 253 ++++++++++++++++++ ...e-checkout-delivery-mode.component.spec.ts | 94 +++++++ ...ervice-checkout-delivery-mode.component.ts | 28 ++ .../service-checkout-delivery-mode.module.ts | 45 ++++ ...vice-checkout-review-submit.component.html | 4 +- ...e-checkout-review-submit.component.spec.ts | 45 +++- ...ervice-checkout-review-submit.component.ts | 8 +- ...checkout-service-details.component.spec.ts | 27 +- ...kout-service-order-steps-set.guard.spec.ts | 126 ++++++++- .../checkout-service-order-steps-set.guard.ts | 52 +++- .../s4-service/checkout/components/index.ts | 3 + .../s4-service-checkout-component.module.ts | 7 +- .../checkout-service-details.service.spec.ts | 49 +++- .../checkout-service-details.service.ts | 18 ++ .../s4-service/order/components/index.ts | 1 + .../service-details-card.component.html | 2 +- .../service-details-card.component.spec.ts | 32 +++ .../service-details-card.component.ts | 16 ++ .../service-details-card.module.ts | 7 + ...e-order-overview-component.service.spec.ts | 45 ++++ ...ervice-order-overview-component.service.ts | 20 ++ .../default-service-delivery-mode-config.ts | 22 ++ .../facade/checkout-service-details.facade.ts | 12 + .../root/model/augmented-types.model.ts | 16 +- .../s4-service/root/model/index.ts | 1 + .../s4-service/root/s4-service-root.module.ts | 2 + .../service-order-checkout-e2e.cy.ts | 56 ++-- .../helpers/vendor/s4-service/s4-service.ts | 110 +++++--- 37 files changed, 1093 insertions(+), 104 deletions(-) create mode 100644 feature-libs/order/components/order-details/order-overview/order-orverview-component.service.spec.ts create mode 100644 feature-libs/order/components/order-details/order-overview/order-overview-component.service.ts create mode 100644 integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.html create mode 100644 integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.spec.ts create mode 100644 integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.ts create mode 100644 integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.module.ts create mode 100644 integration-libs/s4-service/order/components/order-summary/service-order-overview-component.service.spec.ts create mode 100644 integration-libs/s4-service/order/components/order-summary/service-order-overview-component.service.ts create mode 100644 integration-libs/s4-service/root/config/default-service-delivery-mode-config.ts diff --git a/.env-cmdrc b/.env-cmdrc index db37a0b5802..60fdf05f15f 100644 --- a/.env-cmdrc +++ b/.env-cmdrc @@ -87,7 +87,7 @@ "CX_OPPS": "true" }, "s4-service":{ - "CX_BASE_URL": "https://api.cg79x9wuu9-eccommerc1-s1-public.model-t.myhybris.cloud", + "CX_BASE_URL": "https://api.cg79x9wuu9-eccommerc1-s8-public.model-t.myhybris.cloud", "CX_S4_SERVICE": "true", "CX_B2B": "true" } diff --git a/feature-libs/order/components/order-details/index.ts b/feature-libs/order/components/order-details/index.ts index 1a9532fbe68..e68f55beb77 100644 --- a/feature-libs/order/components/order-details/index.ts +++ b/feature-libs/order/components/order-details/index.ts @@ -13,4 +13,5 @@ export * from './order-details.module'; export * from './order-details.service'; export * from './my-account-v2-order-consignments.service'; export * from './order-overview/order-overview.component'; +export * from './order-overview/order-overview-component.service'; export * from './my-account-v2/index'; diff --git a/feature-libs/order/components/order-details/order-details.module.ts b/feature-libs/order/components/order-details/order-details.module.ts index 5410539d49b..ddbe9c4600e 100644 --- a/feature-libs/order/components/order-details/order-details.module.ts +++ b/feature-libs/order/components/order-details/order-details.module.ts @@ -49,6 +49,7 @@ import { ReorderDialogComponent } from './order-detail-reorder/reorder-dialog/re import { OrderDetailTotalsComponent } from './order-detail-totals/order-detail-totals.component'; import { OrderOverviewComponent } from './order-overview/order-overview.component'; import { defaultReorderLayoutConfig } from './reoder-layout.config'; +import { OrderOverviewComponentService } from './order-overview/order-overview-component.service'; function registerOrderOutletFactory(): () => void { const isMyAccountV2 = inject(USE_MY_ACCOUNT_V2_ORDER); @@ -111,6 +112,7 @@ const moduleComponents = [ AbstractOrderContextModule, ], providers: [ + OrderOverviewComponentService, provideDefaultConfig({ cmsComponents: { AccountOrderDetailsActionsComponent: { diff --git a/feature-libs/order/components/order-details/order-overview/order-orverview-component.service.spec.ts b/feature-libs/order/components/order-details/order-overview/order-orverview-component.service.spec.ts new file mode 100644 index 00000000000..0e8009470d2 --- /dev/null +++ b/feature-libs/order/components/order-details/order-overview/order-orverview-component.service.spec.ts @@ -0,0 +1,28 @@ +import { TestBed } from '@angular/core/testing'; +import { OrderOverviewComponentService } from './order-overview-component.service'; +import { Order } from '@spartacus/order/root'; + +describe('OrderOverviewComponentService', () => { + let service: OrderOverviewComponentService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [OrderOverviewComponentService], + }); + service = TestBed.inject(OrderOverviewComponentService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + it('should return true if delivery mode is defined', () => { + const order: Order = { + deliveryMode: { code: 'd1' }, + }; + expect(service.shouldShowDeliveryMode(order?.deliveryMode)).toEqual(true); + }); + it('should return false if delivery mode is not defined', () => { + const order: Order = {}; + expect(service.shouldShowDeliveryMode(order?.deliveryMode)).toEqual(false); + }); +}); diff --git a/feature-libs/order/components/order-details/order-overview/order-overview-component.service.ts b/feature-libs/order/components/order-details/order-overview/order-overview-component.service.ts new file mode 100644 index 00000000000..f1414c78bf3 --- /dev/null +++ b/feature-libs/order/components/order-details/order-overview/order-overview-component.service.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { DeliveryMode } from '@spartacus/cart/base/root'; + +@Injectable() +export class OrderOverviewComponentService { + shouldShowDeliveryMode(mode: DeliveryMode | undefined): boolean { + return mode !== undefined; + } +} diff --git a/feature-libs/order/components/order-details/order-overview/order-overview.component.html b/feature-libs/order/components/order-details/order-overview/order-overview.component.html index bb719197824..18b000975c0 100644 --- a/feature-libs/order/components/order-details/order-overview/order-overview.component.html +++ b/feature-libs/order/components/order-details/order-overview/order-overview.component.html @@ -100,7 +100,7 @@ > - + diff --git a/feature-libs/order/components/order-details/order-overview/order-overview.component.spec.ts b/feature-libs/order/components/order-details/order-overview/order-overview.component.spec.ts index 2767ff234a5..ca7fbc25ce9 100644 --- a/feature-libs/order/components/order-details/order-overview/order-overview.component.spec.ts +++ b/feature-libs/order/components/order-details/order-overview/order-overview.component.spec.ts @@ -13,6 +13,7 @@ import { Card, CmsComponentData } from '@spartacus/storefront'; import { EMPTY, Observable, of } from 'rxjs'; import { OrderDetailsService } from '../order-details.service'; import { OrderOverviewComponent } from './order-overview.component'; +import { OrderOverviewComponentService } from './order-overview-component.service'; @Component({ selector: 'cx-card', template: '' }) class MockCardComponent { @@ -125,6 +126,11 @@ class MockOrderDetailsService { return of(mockOrder); } } +class MockOrderOverviewComponentService { + shouldShowDeliveryMode(_mode: DeliveryMode): boolean { + return true; + } +} const mockData: CmsOrderDetailOverviewComponent = { simple: false, @@ -139,6 +145,7 @@ describe('OrderOverviewComponent', () => { let fixture: ComponentFixture; let translationService: TranslationService; let orderDetailsService: OrderDetailsService; + let componentService: OrderOverviewComponentService; beforeEach(() => { TestBed.configureTestingModule({ @@ -146,6 +153,10 @@ describe('OrderOverviewComponent', () => { declarations: [OrderOverviewComponent, MockCardComponent], providers: [ { provide: TranslationService, useClass: MockTranslationService }, + { + provide: OrderOverviewComponentService, + useClass: MockOrderOverviewComponentService, + }, { provide: OrderDetailsService, useClass: MockOrderDetailsService }, { provide: CmsComponentData, useValue: MockCmsComponentData }, ], @@ -157,6 +168,7 @@ describe('OrderOverviewComponent', () => { component = fixture.componentInstance; translationService = TestBed.inject(TranslationService); orderDetailsService = TestBed.inject(OrderDetailsService); + componentService = TestBed.inject(OrderOverviewComponentService); }); it('should create', () => { @@ -517,4 +529,23 @@ describe('OrderOverviewComponent', () => { expect(address).toEqual(mockFormattedAddress); }); }); + + describe('show delivery mode in order summary', () => { + it('should show delivery mode card in order summary', () => { + spyOn(componentService, 'shouldShowDeliveryMode').and.returnValue(true); + const result = component.shouldShowDeliveryMode(mockDeliveryMode); + expect(result).toEqual(true); + expect(componentService.shouldShowDeliveryMode).toHaveBeenCalledWith( + mockDeliveryMode + ); + }); + it('should not show delivery mode card in order summary', () => { + spyOn(componentService, 'shouldShowDeliveryMode').and.returnValue(false); + const result = component.shouldShowDeliveryMode(undefined); + expect(result).toEqual(false); + expect(componentService.shouldShowDeliveryMode).toHaveBeenCalledWith( + undefined + ); + }); + }); }); diff --git a/feature-libs/order/components/order-details/order-overview/order-overview.component.ts b/feature-libs/order/components/order-details/order-overview/order-overview.component.ts index b95f507e920..814a185e2bb 100644 --- a/feature-libs/order/components/order-details/order-overview/order-overview.component.ts +++ b/feature-libs/order/components/order-details/order-overview/order-overview.component.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { CartOutlets, DeliveryMode } from '@spartacus/cart/base/root'; import { Address, @@ -18,6 +18,7 @@ import { Observable, combineLatest, of } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { OrderDetailsService } from '../order-details.service'; import { OrderOutlets, paymentMethodCard } from '@spartacus/order/root'; +import { OrderOverviewComponentService } from './order-overview-component.service'; @Component({ selector: 'cx-order-overview', @@ -25,6 +26,9 @@ import { OrderOutlets, paymentMethodCard } from '@spartacus/order/root'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class OrderOverviewComponent { + protected orderOverviewComponentService = inject( + OrderOverviewComponentService + ); readonly cartOutlets = CartOutlets; readonly orderOutlets = OrderOutlets; @@ -192,6 +196,9 @@ export class OrderOverviewComponent { ); } + shouldShowDeliveryMode(mode: DeliveryMode | undefined): boolean { + return this.orderOverviewComponentService.shouldShowDeliveryMode(mode); + } getDeliveryModeCardContent(deliveryMode: DeliveryMode): Observable { return this.translation.translate('orderDetails.shippingMethod').pipe( filter(() => Boolean(deliveryMode)), diff --git a/integration-libs/s4-service/assets/translations/en/s4-service.json b/integration-libs/s4-service/assets/translations/en/s4-service.json index a04fd37b13c..fc814f6fd7c 100644 --- a/integration-libs/s4-service/assets/translations/en/s4-service.json +++ b/integration-libs/s4-service/assets/translations/en/s4-service.json @@ -6,7 +6,11 @@ "serviceLocationHeading": "Service Location", "datePickerLabel": "Schedule Service Date", "timePickerLabel": "Schedule Service Time", - "unknownError": "An unknown error occurred. Please contact support." + "unknownError": "An unknown error occurred. Please contact support.", + "productDeliveryOptions": "Delivery Options for Products", + "serviceDeliveryOption": "Delivery Option for Services", + "productDeliveryMethods": "Delivery Methods for Products", + "serviceDeliveryMethod": "Delivery Method for Services" }, "cancelService": { "heading": "The following items will be included in the cancellation request", diff --git a/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.html b/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.html new file mode 100644 index 00000000000..9e7a56acff9 --- /dev/null +++ b/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.html @@ -0,0 +1,253 @@ +
+ + + + {{ 'serviceOrderCheckout.productDeliveryMethods' | cxTranslate }} + + + + + + {{ 'serviceOrderCheckout.productDeliveryOptions' | cxTranslate }} + + + + +
+
+
+ + +
+ + +
+
+
+ + + +

+ {{ 'checkoutMode.productDeliveryMethods' | cxTranslate }} +

+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +

+ {{ 'checkoutMode.deliveryEntries' | cxTranslate }} +

+ + + +
+ + +
+ +
+
+ + + +
+
+ +
+
+ +
+
+ + + + + + + + + + + + {{ 'serviceOrderCheckout.serviceDeliveryMethod' | cxTranslate }} + + + + + {{ 'serviceOrderCheckout.serviceDeliveryOption' | cxTranslate }} + + +
+
+ +
+
+
+ +

+ {{ 'checkoutMode.serviceDeliveryMethod' | cxTranslate }} +

+
+
+ +
+
+
+
+
diff --git a/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.spec.ts b/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.spec.ts new file mode 100644 index 00000000000..294b5bdec17 --- /dev/null +++ b/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.spec.ts @@ -0,0 +1,94 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { GlobalMessageService, I18nTestingModule } from '@spartacus/core'; +import { ServiceCheckoutDeliveryModeComponent } from './service-checkout-delivery-mode.component'; +import { ActivatedRoute } from '@angular/router'; +import { CheckoutStepService } from '@spartacus/checkout/base/components'; +import createSpy = jasmine.createSpy; +import { ReactiveFormsModule } from '@angular/forms'; +import { OutletModule } from '@spartacus/storefront'; +import { + CheckoutServiceDetailsFacade, + S4ServiceDeliveryModeConfig, +} from '@spartacus/s4-service/root'; +import { BehaviorSubject, of } from 'rxjs'; +import { ActiveCartFacade, Cart, OrderEntry } from '@spartacus/cart/base/root'; +const mockCart: Cart = { + code: '123456789', + description: 'testCartDescription', + name: 'testCartName', +}; +const deliveryEntries$ = new BehaviorSubject([ + { orderCode: 'testEntry' }, +]); +const hasPickupItems$ = new BehaviorSubject(false); +const cart$ = new BehaviorSubject(mockCart); +const mockActivatedRoute = { + snapshot: { + url: ['checkout', 'delivery-mode'], + }, +}; +const mockServiceDeliveryModeConfig: S4ServiceDeliveryModeConfig = { + s4ServiceDeliveryMode: { + code: 'd1', + }, +}; +class MockCheckoutStepService implements Partial { + next = createSpy(); + back = createSpy(); + getBackBntText = createSpy().and.returnValue('common.back'); +} +class MockGlobalMessageService implements Partial { + add() {} +} +class MockCheckoutServiceDetailsFacade { + hasServiceItems() { + return of(true); + } +} +class MockCartService implements Partial { + getDeliveryEntries = () => deliveryEntries$.asObservable(); + hasPickupItems = () => hasPickupItems$.asObservable(); + getPickupEntries = createSpy().and.returnValue(of([])); + getActive = () => cart$.asObservable(); +} +describe('ServiceCheckoutDeliveryModeComponent', () => { + let component: ServiceCheckoutDeliveryModeComponent; + let fixture: ComponentFixture; + let facade: CheckoutServiceDetailsFacade; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, I18nTestingModule, OutletModule], + declarations: [ServiceCheckoutDeliveryModeComponent], + providers: [ + { + provide: CheckoutServiceDetailsFacade, + useClass: MockCheckoutServiceDetailsFacade, + }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: ActiveCartFacade, useClass: MockCartService }, + { provide: CheckoutStepService, useClass: MockCheckoutStepService }, + { provide: GlobalMessageService, useClass: MockGlobalMessageService }, + { + provide: S4ServiceDeliveryModeConfig, + useValue: mockServiceDeliveryModeConfig, + }, + ], + }).compileComponents(); + facade = TestBed.inject(CheckoutServiceDetailsFacade); + spyOn(facade, 'hasServiceItems').and.callThrough(); + fixture = TestBed.createComponent(ServiceCheckoutDeliveryModeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should be created', (done) => { + expect(component).toBeTruthy(); + expect(component.serviceDeliveryConfig).toEqual({ code: 'd1' }); + component.hasServiceProducts$.subscribe((result) => { + expect(result).toEqual(true); + expect(facade.hasServiceItems).toHaveBeenCalled(); + done(); + }); + }); +}); diff --git a/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.ts b/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.ts new file mode 100644 index 00000000000..207b7287b98 --- /dev/null +++ b/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { CheckoutDeliveryModeComponent } from '@spartacus/checkout/base/components'; +import { + CheckoutServiceDetailsFacade, + S4ServiceDeliveryModeConfig, +} from '@spartacus/s4-service/root'; +import { Observable } from 'rxjs'; + +@Component({ + selector: 'cx-delivery-mode', + templateUrl: './service-checkout-delivery-mode.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ServiceCheckoutDeliveryModeComponent extends CheckoutDeliveryModeComponent { + protected checkoutServiceDetailsFacade = inject(CheckoutServiceDetailsFacade); + protected config = inject(S4ServiceDeliveryModeConfig); + + hasServiceProducts$: Observable = + this.checkoutServiceDetailsFacade.hasServiceItems(); + + serviceDeliveryConfig = this.config.s4ServiceDeliveryMode; +} diff --git a/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.module.ts b/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.module.ts new file mode 100644 index 00000000000..c80e8651d56 --- /dev/null +++ b/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.module.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { + provideDefaultConfig, + CmsConfig, + I18nModule, + FeaturesConfigModule, +} from '@spartacus/core'; +import { ServiceCheckoutDeliveryModeComponent } from './service-checkout-delivery-mode.component'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; +import { + SpinnerModule, + OutletModule, + PageComponentModule, +} from '@spartacus/storefront'; + +@NgModule({ + imports: [ + CommonModule, + ReactiveFormsModule, + I18nModule, + SpinnerModule, + OutletModule, + PageComponentModule, + FeaturesConfigModule, + ], + providers: [ + provideDefaultConfig({ + cmsComponents: { + CheckoutDeliveryMode: { + component: ServiceCheckoutDeliveryModeComponent, + }, + }, + }), + ], + declarations: [ServiceCheckoutDeliveryModeComponent], + exports: [ServiceCheckoutDeliveryModeComponent], +}) +export class ServiceCheckoutDeliveryModeModule {} diff --git a/integration-libs/s4-service/checkout/components/checkout-review-submit/service-checkout-review-submit.component.html b/integration-libs/s4-service/checkout/components/checkout-review-submit/service-checkout-review-submit.component.html index 790ce472e57..2a8e848ef4e 100644 --- a/integration-libs/s4-service/checkout/components/checkout-review-submit/service-checkout-review-submit.component.html +++ b/integration-libs/s4-service/checkout/components/checkout-review-submit/service-checkout-review-submit.component.html @@ -149,9 +149,9 @@

-
+
diff --git a/integration-libs/s4-service/checkout/components/checkout-review-submit/service-checkout-review-submit.component.spec.ts b/integration-libs/s4-service/checkout/components/checkout-review-submit/service-checkout-review-submit.component.spec.ts index b26ea78ea85..9f80369bee9 100644 --- a/integration-libs/s4-service/checkout/components/checkout-review-submit/service-checkout-review-submit.component.spec.ts +++ b/integration-libs/s4-service/checkout/components/checkout-review-submit/service-checkout-review-submit.component.spec.ts @@ -40,8 +40,13 @@ import { CheckoutServiceDetailsFacade, CheckoutServiceSchedulePickerService, ServiceDateTime, + S4ServiceDeliveryModeConfig, } from '@spartacus/s4-service/root'; - +const mockServiceDeliveryModeConfig: S4ServiceDeliveryModeConfig = { + s4ServiceDeliveryMode: { + code: 'fast-service', + }, +}; const mockCart: Cart = { guid: 'test', code: 'test', @@ -153,8 +158,11 @@ class MockCheckoutServiceDetails getSelectedServiceDetailsState(): Observable> { return of({ loading: false, error: false, data: mockScheduledAt }); } - getServiceProducts(): Observable { - return of([]); + hasServiceItems(): Observable { + return of(false); + } + hasNonServiceItems(): Observable { + return of(false); } } @@ -294,6 +302,10 @@ describe('ServiceCheckoutReviewSubmitComponent', () => { provide: CheckoutServiceSchedulePickerService, useClass: MockCheckoutServiceSchedulePickerService, }, + { + provide: S4ServiceDeliveryModeConfig, + useValue: mockServiceDeliveryModeConfig, + }, ], }).compileComponents(); })); @@ -484,6 +496,14 @@ describe('ServiceCheckoutReviewSubmitComponent', () => { done(); }); }); + it('should call getServiceDetailsCard() to get service details and return empty card if scheduledAt is empty', (done) => { + component.getServiceDetailsCard(undefined).subscribe((card) => { + expect(card.title).toEqual('serviceOrderCheckout.serviceDetails'); + expect(card.textBold).toEqual(''); + expect(card.text).toEqual(['']); + done(); + }); + }); it('should get checkout step url', () => { expect( @@ -506,4 +526,23 @@ describe('ServiceCheckoutReviewSubmitComponent', () => { expect(getCartTotalText()).toContain('$999.98'); }); }); + it('should not show delivery mode card in review page', () => { + const mode1: DeliveryMode = { + code: 'fast-service', + description: 'Fast delivery mode', + }; + expect(component.shouldShowDeliveryModeCard(mode1)).toEqual(false); + }); + it('should show delivery mode card in review page', () => { + const mode2: DeliveryMode = { + code: 'super-fast-service', + description: 'Super Fast delivery mode', + }; + const mode3: DeliveryMode = { + name: 'super-fast-service', + description: 'Super Fast delivery mode', + }; + expect(component.shouldShowDeliveryModeCard(mode2)).toEqual(true); + expect(component.shouldShowDeliveryModeCard(mode3)).toEqual(true); + }); }); diff --git a/integration-libs/s4-service/checkout/components/checkout-review-submit/service-checkout-review-submit.component.ts b/integration-libs/s4-service/checkout/components/checkout-review-submit/service-checkout-review-submit.component.ts index fb25bb90496..b0ccf327e6f 100644 --- a/integration-libs/s4-service/checkout/components/checkout-review-submit/service-checkout-review-submit.component.ts +++ b/integration-libs/s4-service/checkout/components/checkout-review-submit/service-checkout-review-submit.component.ts @@ -5,7 +5,7 @@ */ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { ActiveCartFacade } from '@spartacus/cart/base/root'; +import { ActiveCartFacade, DeliveryMode } from '@spartacus/cart/base/root'; import { B2BCheckoutReviewSubmitComponent } from '@spartacus/checkout/b2b/components'; import { CheckoutCostCenterFacade, @@ -26,6 +26,7 @@ import { CheckoutServiceDetailsFacade, CheckoutServiceSchedulePickerService, ServiceDateTime, + S4ServiceDeliveryModeConfig, } from '@spartacus/s4-service/root'; @Component({ @@ -39,6 +40,7 @@ export class ServiceCheckoutReviewSubmitComponent extends B2BCheckoutReviewSubmi protected checkoutServiceSchedulePickerService = inject( CheckoutServiceSchedulePickerService ); + protected config = inject(S4ServiceDeliveryModeConfig); constructor( protected checkoutDeliveryAddressFacade: CheckoutDeliveryAddressFacade, @@ -82,6 +84,10 @@ export class ServiceCheckoutReviewSubmitComponent extends B2BCheckoutReviewSubmi ]; } + shouldShowDeliveryModeCard(mode: DeliveryMode): boolean { + return mode.code !== this.config.s4ServiceDeliveryMode?.code; + } + getServiceDetailsCard( scheduledAt: ServiceDateTime | undefined | null ): Observable { diff --git a/integration-libs/s4-service/checkout/components/checkout-service-details/checkout-service-details.component.spec.ts b/integration-libs/s4-service/checkout/components/checkout-service-details/checkout-service-details.component.spec.ts index f19398aa354..b5a83f7c0f4 100644 --- a/integration-libs/s4-service/checkout/components/checkout-service-details/checkout-service-details.component.spec.ts +++ b/integration-libs/s4-service/checkout/components/checkout-service-details/checkout-service-details.component.spec.ts @@ -40,8 +40,9 @@ class MockCheckoutServiceDetailsFacade { return of({ success: true }); } } -class MockCheckoutServiceSchedulePickerService { - // implements Partial +class MockCheckoutServiceSchedulePickerService + implements Partial +{ getMinDateForService = createSpy().and.returnValue(of('2024-06-25')); getScheduledServiceTimes = createSpy().and.returnValue( of(['8:30', '9:30', '10:30']) @@ -108,6 +109,14 @@ describe('CheckoutServiceDetailsComponent', () => { expect(component.form?.get('scheduleTime')?.value).toEqual('09:30'); expect(pickerService.getServiceDetailsFromDateTime).toHaveBeenCalled(); }); + it('should call ngOnInit and set minimum date and earliest time, if no value was previously selected', () => { + component.minServiceDate$ = of('2030-12-12'); + component.selectedServiceDetails$ = of(''); + component.scheduleTimes$ = of(['13:13', '14:14', '15:15']); + component.ngOnInit(); + expect(component.form?.get('scheduleDate')?.value).toEqual('2030-12-12'); + expect(component.form?.get('scheduleTime')?.value).toEqual('13:13'); + }); it('should get back button text', () => { component.back(); expect(checkoutStepService.getBackBntText).toHaveBeenCalled(); @@ -157,4 +166,18 @@ describe('CheckoutServiceDetailsComponent', () => { expect(checkoutStepService.next).not.toHaveBeenCalled(); expect(messageService.add).toHaveBeenCalled(); }); + it('should throw error if we pass inappropriate scheduledAt', () => { + spyOn( + checkoutServiceDetailsFacade, + 'setServiceScheduleSlot' + ).and.returnValue(throwError('Throwing Error message')); + (pickerService.convertToDateTime as jasmine.Spy).and.returnValue(''); + component.form = new UntypedFormBuilder().group({}); + component.next(); + expect(pickerService.convertToDateTime).toHaveBeenCalledWith('', ''); + expect( + checkoutServiceDetailsFacade.setServiceScheduleSlot + ).toHaveBeenCalledWith(''); + expect(messageService.add).toHaveBeenCalled(); + }); }); diff --git a/integration-libs/s4-service/checkout/components/guards/checkout-service-order-steps-set.guard.spec.ts b/integration-libs/s4-service/checkout/components/guards/checkout-service-order-steps-set.guard.spec.ts index dfa16c23581..1f63fd558cb 100644 --- a/integration-libs/s4-service/checkout/components/guards/checkout-service-order-steps-set.guard.spec.ts +++ b/integration-libs/s4-service/checkout/components/guards/checkout-service-order-steps-set.guard.spec.ts @@ -1,7 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { CheckoutServiceOrderStepsSetGuard } from './checkout-service-order-steps-set.guard'; import { BehaviorSubject, Observable, of } from 'rxjs'; -import { CheckoutServiceDetailsFacade } from '@spartacus/s4-service/root'; +import { + CheckoutServiceDetailsFacade, + S4ServiceDeliveryModeConfig, +} from '@spartacus/s4-service/root'; import { Address, CostCenter, @@ -30,7 +33,11 @@ import { } from '@spartacus/checkout/b2b/root'; import { CheckoutStepService } from '@spartacus/checkout/base/components'; import createSpy = jasmine.createSpy; - +const mockServiceDeliveryModeConfig: S4ServiceDeliveryModeConfig = { + s4ServiceDeliveryMode: { + code: 'my-service-delivery-mode', + }, +}; class MockRoutingConfigService implements Partial { getRouteConfig(stepRoute: string): RouteConfig | undefined { if (stepRoute === 'route0') { @@ -130,6 +137,9 @@ class MockCheckoutDeliveryModeFacade > { return of({ loading: false, error: false, data: undefined }); } + setDeliveryMode(_mode: string): Observable { + return of(undefined); + } } class MockCheckoutPaymentFacade implements Partial { @@ -148,14 +158,18 @@ class MockCheckoutServiceDetailsFacade getSelectedServiceDetailsState(): Observable> { return of({ loading: false, error: false, data: mockScheduledAt }); } - getServiceProducts(): Observable { - return of(['456']); + hasServiceItems(): Observable { + return of(true); + } + hasNonServiceItems(): Observable { + return of(false); } } describe('CheckoutServiceOrderStepsSetGuard', () => { let guard: CheckoutServiceOrderStepsSetGuard; let facade: CheckoutServiceDetailsFacade; + let deliveryModeFacade: CheckoutDeliveryModesFacade; let stepService: CheckoutStepService; beforeEach(() => { TestBed.configureTestingModule({ @@ -165,6 +179,10 @@ describe('CheckoutServiceOrderStepsSetGuard', () => { provide: CheckoutServiceDetailsFacade, useClass: MockCheckoutServiceDetailsFacade, }, + { + provide: S4ServiceDeliveryModeConfig, + useValue: mockServiceDeliveryModeConfig, + }, CheckoutB2BStepsSetGuard, { provide: CheckoutStepService, useClass: MockCheckoutStepService }, { @@ -194,29 +212,70 @@ describe('CheckoutServiceOrderStepsSetGuard', () => { guard = TestBed.inject(CheckoutServiceOrderStepsSetGuard); stepService = TestBed.inject(CheckoutStepService); facade = TestBed.inject(CheckoutServiceDetailsFacade); + deliveryModeFacade = TestBed.inject(CheckoutDeliveryModesFacade); }); it('should be created', () => { expect(guard).toBeTruthy(); }); - it('should disable service details tab if no service products exists', (done) => { - spyOn(facade, 'getServiceProducts').and.returnValue(of([])); + it('should disable service details tab if cart has no service products and no physical products', (done) => { + spyOn(facade, 'hasServiceItems').and.returnValue(of(false)); + spyOn(facade, 'hasNonServiceItems').and.returnValue(of(false)); spyOn(stepService, 'disableEnableStep').and.returnValue(); guard.canActivate({ url: ['checkout', 'route3'] }).subscribe(() => { expect(stepService.disableEnableStep).toHaveBeenCalledWith( CheckoutStepType.SERVICE_DETAILS, true ); + expect(stepService.disableEnableStep).toHaveBeenCalledWith( + CheckoutStepType.DELIVERY_MODE, + true + ); done(); }); }); - it('should enable service details tab if service products exists', (done) => { - spyOn(facade, 'getServiceProducts').and.returnValue( - of(['service-1', 'service-2']) - ); + it('should disable service details tab if cart has no service products but physical products', (done) => { + spyOn(facade, 'hasServiceItems').and.returnValue(of(false)); + spyOn(facade, 'hasNonServiceItems').and.returnValue(of(true)); spyOn(stepService, 'disableEnableStep').and.returnValue(); guard.canActivate({ url: ['checkout', 'route3'] }).subscribe(() => { expect(stepService.disableEnableStep).toHaveBeenCalledWith( CheckoutStepType.SERVICE_DETAILS, + true + ); + expect(stepService.disableEnableStep).toHaveBeenCalledWith( + CheckoutStepType.DELIVERY_MODE, + false + ); + done(); + }); + }); + it('should enable service details tab if service products exists but no physical product in cart', (done) => { + spyOn(facade, 'hasServiceItems').and.returnValue(of(true)); + spyOn(facade, 'hasNonServiceItems').and.returnValue(of(false)); + spyOn(stepService, 'disableEnableStep').and.returnValue(); + guard.canActivate({ url: ['checkout', 'route3'] }).subscribe(() => { + expect(stepService.disableEnableStep).toHaveBeenCalledWith( + CheckoutStepType.SERVICE_DETAILS, + false + ); + expect(stepService.disableEnableStep).toHaveBeenCalledWith( + CheckoutStepType.DELIVERY_MODE, + true + ); + done(); + }); + }); + it('should enable service details tab if both service products and physical products exists in cart', (done) => { + spyOn(facade, 'hasServiceItems').and.returnValue(of(true)); + spyOn(facade, 'hasNonServiceItems').and.returnValue(of(true)); + spyOn(stepService, 'disableEnableStep').and.returnValue(); + guard.canActivate({ url: ['checkout', 'route3'] }).subscribe(() => { + expect(stepService.disableEnableStep).toHaveBeenCalledWith( + CheckoutStepType.SERVICE_DETAILS, + false + ); + expect(stepService.disableEnableStep).toHaveBeenCalledWith( + CheckoutStepType.DELIVERY_MODE, false ); done(); @@ -224,7 +283,9 @@ describe('CheckoutServiceOrderStepsSetGuard', () => { }); it('should move to next step once service details are set', () => { spyOn(facade, 'getSelectedServiceDetailsState').and.callThrough(); - spyOn(facade, 'getServiceProducts').and.callThrough(); + spyOn(facade, 'hasServiceItems').and.callThrough(); + spyOn(facade, 'hasNonServiceItems').and.callThrough(); + spyOn(guard, 'setServiceDeliveryMode').and.returnValue(of(undefined)); (guard as any) .isServiceDetailsSet({ type: CheckoutStepType.SERVICE_DETAILS, @@ -238,7 +299,9 @@ describe('CheckoutServiceOrderStepsSetGuard', () => { spyOn(facade, 'getSelectedServiceDetailsState').and.returnValue( of({ loading: false, error: false, data: undefined }) ); - spyOn(facade, 'getServiceProducts').and.returnValue(of(['a', 'b'])); + spyOn(facade, 'hasServiceItems').and.callThrough(); + spyOn(facade, 'hasNonServiceItems').and.callThrough(); + spyOn(guard, 'setServiceDeliveryMode').and.returnValue(of(undefined)); spyOn(guard as any, 'getUrl').and.returnValue('/'); (guard as any) .isServiceDetailsSet({ @@ -352,5 +415,44 @@ describe('CheckoutServiceOrderStepsSetGuard', () => { done(); }); }); + it('should set delivery mode to service-delivery if the cart contains only service products', (done) => { + spyOn(facade, 'hasServiceItems').and.returnValue(of(true)); + spyOn(facade, 'hasNonServiceItems').and.returnValue(of(false)); + spyOn(deliveryModeFacade, 'setDeliveryMode').and.returnValue( + of(undefined) + ); + guard.setServiceDeliveryMode().subscribe(() => { + expect(deliveryModeFacade.setDeliveryMode).toHaveBeenCalledWith( + 'my-service-delivery-mode' + ); + done(); + }); + }); + it('should not set delivery mode to service-delivery if the cart contains service products + physical products', (done) => { + spyOn(facade, 'hasServiceItems').and.returnValue(of(true)); + spyOn(facade, 'hasNonServiceItems').and.returnValue(of(true)); + spyOn(deliveryModeFacade, 'setDeliveryMode').and.returnValue( + of(undefined) + ); + guard.setServiceDeliveryMode().subscribe(() => { + expect(deliveryModeFacade.setDeliveryMode).not.toHaveBeenCalledWith( + 'my-service-delivery-mode' + ); + done(); + }); + }); + it('should not set delivery mode to service-delivery if the cart contains only physical products', (done) => { + spyOn(facade, 'hasServiceItems').and.returnValue(of(false)); + spyOn(facade, 'hasNonServiceItems').and.returnValue(of(true)); + spyOn(deliveryModeFacade, 'setDeliveryMode').and.returnValue( + of(undefined) + ); + guard.setServiceDeliveryMode().subscribe(() => { + expect(deliveryModeFacade.setDeliveryMode).not.toHaveBeenCalledWith( + 'my-service-delivery-mode' + ); + done(); + }); + }); }); }); diff --git a/integration-libs/s4-service/checkout/components/guards/checkout-service-order-steps-set.guard.ts b/integration-libs/s4-service/checkout/components/guards/checkout-service-order-steps-set.guard.ts index 317d7d8a48b..09221b3930c 100644 --- a/integration-libs/s4-service/checkout/components/guards/checkout-service-order-steps-set.guard.ts +++ b/integration-libs/s4-service/checkout/components/guards/checkout-service-order-steps-set.guard.ts @@ -6,28 +6,42 @@ import { Injectable, inject } from '@angular/core'; import { ActivatedRouteSnapshot, UrlTree } from '@angular/router'; +import { ActiveCartFacade } from '@spartacus/cart/base/root'; import { CheckoutB2BStepsSetGuard } from '@spartacus/checkout/b2b/components'; import { CheckoutStep, CheckoutStepType } from '@spartacus/checkout/base/root'; -import { CheckoutServiceDetailsFacade } from '@spartacus/s4-service/root'; -import { Observable, filter, map, of, switchMap } from 'rxjs'; +import { + CheckoutServiceDetailsFacade, + S4ServiceDeliveryModeConfig, +} from '@spartacus/s4-service/root'; +import { Observable, combineLatest, filter, map, of, switchMap } from 'rxjs'; @Injectable({ providedIn: 'root', }) export class CheckoutServiceOrderStepsSetGuard extends CheckoutB2BStepsSetGuard { protected checkoutServiceDetailsFacade = inject(CheckoutServiceDetailsFacade); + protected activeCartFacade = inject(ActiveCartFacade); + protected config = inject(S4ServiceDeliveryModeConfig); canActivate(route: ActivatedRouteSnapshot): Observable { - return this.checkoutServiceDetailsFacade.getServiceProducts().pipe( - switchMap((products) => { + return combineLatest( + this.checkoutServiceDetailsFacade.hasServiceItems(), + this.checkoutServiceDetailsFacade.hasNonServiceItems() + ).pipe( + switchMap(([hasServiceItems, hasNonServiceItems]) => { this.checkoutStepService.disableEnableStep( CheckoutStepType.SERVICE_DETAILS, - products && products.length === 0 + !hasServiceItems + ); + this.checkoutStepService.disableEnableStep( + CheckoutStepType.DELIVERY_MODE, + !hasNonServiceItems ); return super.canActivate(route); }) ); } + protected isServiceDetailsSet( step: CheckoutStep ): Observable { @@ -35,12 +49,34 @@ export class CheckoutServiceOrderStepsSetGuard extends CheckoutB2BStepsSetGuard .getSelectedServiceDetailsState() .pipe( filter((state) => !state.loading && !state.error), - map((selectedServiceDetails) => - selectedServiceDetails.data ? true : this.getUrl(step.routeName) - ) + switchMap((selectedServiceDetails) => { + return this.setServiceDeliveryMode().pipe( + map(() => { + return selectedServiceDetails.data + ? true + : this.getUrl(step.routeName); + }) + ); + }) ); } + setServiceDeliveryMode(): Observable { + return combineLatest([ + this.checkoutServiceDetailsFacade.hasServiceItems(), + this.checkoutServiceDetailsFacade.hasNonServiceItems(), + ]).pipe( + switchMap(([hasServiceItems, hasNonServiceItems]) => { + if (!hasNonServiceItems && hasServiceItems) { + return this.checkoutDeliveryModesFacade.setDeliveryMode( + this.config.s4ServiceDeliveryMode?.code ?? '' + ); + } + return of(undefined); + }) + ); + } + protected override isB2BStepSet( step: CheckoutStep, isAccountPayment: boolean diff --git a/integration-libs/s4-service/checkout/components/index.ts b/integration-libs/s4-service/checkout/components/index.ts index 13076b3165a..e147f3538f2 100644 --- a/integration-libs/s4-service/checkout/components/index.ts +++ b/integration-libs/s4-service/checkout/components/index.ts @@ -13,3 +13,6 @@ export * from './checkout-service-details/checkout-service-details.component'; export * from './checkout-service-details/checkout-service-details.module'; export * from './s4-service-checkout-component.module'; + +export * from './checkout-delivery-mode/service-checkout-delivery-mode.component'; +export * from './checkout-delivery-mode/service-checkout-delivery-mode.module'; diff --git a/integration-libs/s4-service/checkout/components/s4-service-checkout-component.module.ts b/integration-libs/s4-service/checkout/components/s4-service-checkout-component.module.ts index b8403f25189..6b91a44b5d8 100644 --- a/integration-libs/s4-service/checkout/components/s4-service-checkout-component.module.ts +++ b/integration-libs/s4-service/checkout/components/s4-service-checkout-component.module.ts @@ -9,9 +9,14 @@ import { CheckoutStepsSetGuard } from '@spartacus/checkout/base/components'; import { CheckoutServiceOrderStepsSetGuard } from './guards'; import { ServiceCheckoutReviewSubmitModule } from './checkout-review-submit/service-checkout-review-submit.module'; import { CheckoutServiceDetailsModule } from './checkout-service-details/checkout-service-details.module'; +import { ServiceCheckoutDeliveryModeModule } from './checkout-delivery-mode/service-checkout-delivery-mode.module'; @NgModule({ - imports: [ServiceCheckoutReviewSubmitModule, CheckoutServiceDetailsModule], + imports: [ + ServiceCheckoutReviewSubmitModule, + CheckoutServiceDetailsModule, + ServiceCheckoutDeliveryModeModule, + ], providers: [ { provide: CheckoutStepsSetGuard, diff --git a/integration-libs/s4-service/checkout/core/facade/checkout-service-details.service.spec.ts b/integration-libs/s4-service/checkout/core/facade/checkout-service-details.service.spec.ts index fe896add77d..2320b7adf30 100644 --- a/integration-libs/s4-service/checkout/core/facade/checkout-service-details.service.spec.ts +++ b/integration-libs/s4-service/checkout/core/facade/checkout-service-details.service.spec.ts @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing'; -import { ActiveCartFacade } from '@spartacus/cart/base/root'; +import { ActiveCartFacade, OrderEntry } from '@spartacus/cart/base/root'; import { CheckoutQueryFacade, CheckoutState, @@ -10,7 +10,7 @@ import { QueryState, UserIdService, } from '@spartacus/core'; -import { of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { take } from 'rxjs/operators'; import { CheckoutServiceDetailsConnector } from '../connector'; import { CheckoutServiceDetailsService } from './checkout-service-details.service'; @@ -52,6 +52,9 @@ class MockActiveCartService implements Partial { }, ]); } + getDeliveryEntries(): Observable { + return of([]); + } } class MockUserIdService implements Partial { @@ -178,4 +181,46 @@ describe(`CheckoutServiceDetailsService`, () => { done(); }); }); + it(`should return true if the current cart has non-service products`, (done) => { + const orderEntries: OrderEntry[] = [ + { orderCode: 'deliveryEntry1' }, + { orderCode: 'deliveryEntry2' }, + ]; + spyOn(cartService, 'getDeliveryEntries').and.returnValue(of(orderEntries)); + spyOn(service, 'getServiceProducts').and.returnValue(of(['service 1'])); + service.hasNonServiceItems().subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + it(`should return false if the current cart has no non-service products`, (done) => { + const orderEntries: OrderEntry[] = [ + { orderCode: 'deliveryEntry1' }, + { orderCode: 'deliveryEntry2' }, + ]; + spyOn(cartService, 'getDeliveryEntries').and.returnValue(of(orderEntries)); + spyOn(service, 'getServiceProducts').and.returnValue( + of(['service 1', 'service 2']) + ); + service.hasNonServiceItems().subscribe((result) => { + expect(result).toEqual(false); + done(); + }); + }); + it(`should return true if the current cart has service products`, (done) => { + spyOn(service, 'getServiceProducts').and.returnValue( + of(['service 1', 'service 2']) + ); + service.hasServiceItems().subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + it(`should return false if the current cart has no service products`, (done) => { + spyOn(service, 'getServiceProducts').and.returnValue(of([])); + service.hasServiceItems().subscribe((result) => { + expect(result).toEqual(false); + done(); + }); + }); }); diff --git a/integration-libs/s4-service/checkout/core/facade/checkout-service-details.service.ts b/integration-libs/s4-service/checkout/core/facade/checkout-service-details.service.ts index 06575f2e198..08d6c969980 100644 --- a/integration-libs/s4-service/checkout/core/facade/checkout-service-details.service.ts +++ b/integration-libs/s4-service/checkout/core/facade/checkout-service-details.service.ts @@ -110,4 +110,22 @@ export class CheckoutServiceDetailsService }) ); } + hasNonServiceItems(): Observable { + //Note: Pick up option is not applicable for Service Products + return combineLatest([ + this.activeCartFacade.getDeliveryEntries(), + this.getServiceProducts(), + ]).pipe( + take(1), + map(([allEntries, serviceEntries]) => { + return allEntries.length - serviceEntries.length > 0; + }) + ); + } + + hasServiceItems(): Observable { + return this.getServiceProducts().pipe( + map((products) => products.length > 0) + ); + } } diff --git a/integration-libs/s4-service/order/components/index.ts b/integration-libs/s4-service/order/components/index.ts index 953c7c820e4..a75d25d53cd 100644 --- a/integration-libs/s4-service/order/components/index.ts +++ b/integration-libs/s4-service/order/components/index.ts @@ -8,6 +8,7 @@ export * from './s4-service-components.module'; export * from './guards/index'; export * from './order-summary/service-details-card.component'; export * from './order-summary/service-details-card.module'; +export * from './order-summary/service-order-overview-component.service'; export * from './cancel-service-order/cancel-service-order.component'; export * from './cancel-service-order/cancel-service-order.module'; diff --git a/integration-libs/s4-service/order/components/order-summary/service-details-card.component.html b/integration-libs/s4-service/order/components/order-summary/service-details-card.component.html index f58be40d77e..aa398e5a639 100644 --- a/integration-libs/s4-service/order/components/order-summary/service-details-card.component.html +++ b/integration-libs/s4-service/order/components/order-summary/service-details-card.component.html @@ -1,4 +1,4 @@ - + diff --git a/integration-libs/s4-service/order/components/order-summary/service-details-card.component.spec.ts b/integration-libs/s4-service/order/components/order-summary/service-details-card.component.spec.ts index 1c2033971c1..272a2f7db92 100644 --- a/integration-libs/s4-service/order/components/order-summary/service-details-card.component.spec.ts +++ b/integration-libs/s4-service/order/components/order-summary/service-details-card.component.spec.ts @@ -58,6 +58,15 @@ describe('ServiceDetailsCardComponent', () => { }); }); }); + it('should return empty card', () => { + component.getServiceDetailsCard(undefined).subscribe((card) => { + expect(card).toEqual({ + title: 'card title', + textBold: undefined, + text: [''], + }); + }); + }); it('should call ngOnDestroy', () => { spyOn(component['subscription'], 'unsubscribe'); @@ -73,4 +82,27 @@ describe('ServiceDetailsCardComponent', () => { component.ngOnInit(); expect(component.order).toEqual(order); }); + + it('should show service details card in order summary only if order contains service products', () => { + component.order = { + entries: [ + { product: { productTypes: 'SERVICE' } }, + { product: { productTypes: 'PHYSICAL' } }, + ], + } as any; + expect(component.showServiceDetails()).toEqual(true); + }); + it('should not show service details card in order summary if order doesnot contains service products', () => { + component.order = { + entries: [ + { product: { productTypes: 'PHYSICAL' } }, + { product: { productTypes: 'PHYSICAL' } }, + ], + } as any; + expect(component.showServiceDetails()).toEqual(false); + }); + it('should not show service details card in order summary if order doesnot contains any entries', () => { + component.order = {} as any; + expect(component.showServiceDetails()).toEqual(false); + }); }); diff --git a/integration-libs/s4-service/order/components/order-summary/service-details-card.component.ts b/integration-libs/s4-service/order/components/order-summary/service-details-card.component.ts index 307e2d480f7..91eb1dd0593 100644 --- a/integration-libs/s4-service/order/components/order-summary/service-details-card.component.ts +++ b/integration-libs/s4-service/order/components/order-summary/service-details-card.component.ts @@ -5,6 +5,7 @@ */ import { Component, OnDestroy, OnInit, Optional, inject } from '@angular/core'; +import { OrderEntry } from '@spartacus/cart/base/root'; import { TranslationService } from '@spartacus/core'; import { Order } from '@spartacus/order/root'; import { @@ -36,6 +37,21 @@ export class ServiceDetailsCardComponent implements OnInit, OnDestroy { } } + showServiceDetails(): boolean { + let hasService: boolean = false; + //Note: Pick up option is not applicable for Service Products + const deliveryEntries: OrderEntry[] = + this.order.entries?.filter( + (entry) => entry.deliveryPointOfService === undefined + ) || []; + deliveryEntries.forEach((entry) => { + if (entry.product?.productTypes === 'SERVICE') { + hasService = true; + } + }); + return hasService; + } + getServiceDetailsCard( scheduledAt: ServiceDateTime | undefined ): Observable { diff --git a/integration-libs/s4-service/order/components/order-summary/service-details-card.module.ts b/integration-libs/s4-service/order/components/order-summary/service-details-card.module.ts index 1cee6d12b5b..7176189e87d 100644 --- a/integration-libs/s4-service/order/components/order-summary/service-details-card.module.ts +++ b/integration-libs/s4-service/order/components/order-summary/service-details-card.module.ts @@ -10,10 +10,17 @@ import { CommonModule } from '@angular/common'; import { I18nModule } from '@spartacus/core'; import { ServiceDetailsCardComponent } from './service-details-card.component'; import { OrderOutlets } from '@spartacus/order/root'; +import { ServiceOrderOverviewComponentService } from './service-order-overview-component.service'; +import { OrderOverviewComponentService } from '@spartacus/order/components'; @NgModule({ imports: [CardModule, CommonModule, I18nModule], providers: [ + ServiceOrderOverviewComponentService, + { + provide: OrderOverviewComponentService, + useExisting: ServiceOrderOverviewComponentService, + }, provideOutlet({ id: OrderOutlets.SERVICE_DETAILS, component: ServiceDetailsCardComponent, diff --git a/integration-libs/s4-service/order/components/order-summary/service-order-overview-component.service.spec.ts b/integration-libs/s4-service/order/components/order-summary/service-order-overview-component.service.spec.ts new file mode 100644 index 00000000000..3d7adfcf116 --- /dev/null +++ b/integration-libs/s4-service/order/components/order-summary/service-order-overview-component.service.spec.ts @@ -0,0 +1,45 @@ +import { TestBed } from '@angular/core/testing'; +import { ServiceOrderOverviewComponentService } from './service-order-overview-component.service'; +import { Order } from '@spartacus/order/root'; +import { S4ServiceDeliveryModeConfig } from '@spartacus/s4-service/root'; +const mockServiceDeliveryModeConfig: S4ServiceDeliveryModeConfig = { + s4ServiceDeliveryMode: { + code: 'd1', + }, +}; +describe('ServiceOrderOverviewComponentService', () => { + let service: ServiceOrderOverviewComponentService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ServiceOrderOverviewComponentService, + { + provide: S4ServiceDeliveryModeConfig, + useValue: mockServiceDeliveryModeConfig, + }, + ], + }); + service = TestBed.inject(ServiceOrderOverviewComponentService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + it('should return true if delivery mode is not same as service delivery mode', () => { + const order1: Order = { + deliveryMode: { code: 'd2' }, + }; + expect(service.shouldShowDeliveryMode(order1.deliveryMode)).toEqual(true); + const order2: Order = { + deliveryMode: { name: 'fast-delivery' }, + }; + expect(service.shouldShowDeliveryMode(order2.deliveryMode)).toEqual(true); + }); + it('should return false if delivery mode is not defined', () => { + const order: Order = { + deliveryMode: { code: 'd1', name: 'service-delivery' }, + }; + expect(service.shouldShowDeliveryMode(order?.deliveryMode)).toEqual(false); + }); +}); diff --git a/integration-libs/s4-service/order/components/order-summary/service-order-overview-component.service.ts b/integration-libs/s4-service/order/components/order-summary/service-order-overview-component.service.ts new file mode 100644 index 00000000000..36079b56d51 --- /dev/null +++ b/integration-libs/s4-service/order/components/order-summary/service-order-overview-component.service.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { inject, Injectable } from '@angular/core'; +import { DeliveryMode } from '@spartacus/cart/base/root'; +import { OrderOverviewComponentService } from '@spartacus/order/components'; +import { S4ServiceDeliveryModeConfig } from '@spartacus/s4-service/root'; + +@Injectable() +export class ServiceOrderOverviewComponentService extends OrderOverviewComponentService { + protected config = inject(S4ServiceDeliveryModeConfig); + shouldShowDeliveryMode(mode: DeliveryMode | undefined): boolean { + return mode?.code === this.config.s4ServiceDeliveryMode?.code + ? false + : super.shouldShowDeliveryMode(mode); + } +} diff --git a/integration-libs/s4-service/root/config/default-service-delivery-mode-config.ts b/integration-libs/s4-service/root/config/default-service-delivery-mode-config.ts new file mode 100644 index 00000000000..ec5a71428d5 --- /dev/null +++ b/integration-libs/s4-service/root/config/default-service-delivery-mode-config.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PriceType } from '@spartacus/core'; +import { S4ServiceDeliveryModeConfig } from '../model/augmented-types.model'; + +export const defaultServiceDeliveryModeConfig: S4ServiceDeliveryModeConfig = { + s4ServiceDeliveryMode: { + code: 'service-delivery', + deliveryCost: { + currencyIso: 'USD', + formattedValue: 'USD0.00', + priceType: PriceType.BUY, + value: 0.0, + }, + description: 'Not applicable', + name: 'No Delivery Charges for Service', + }, +}; diff --git a/integration-libs/s4-service/root/facade/checkout-service-details.facade.ts b/integration-libs/s4-service/root/facade/checkout-service-details.facade.ts index 190cc64d4ba..07487613f22 100644 --- a/integration-libs/s4-service/root/facade/checkout-service-details.facade.ts +++ b/integration-libs/s4-service/root/facade/checkout-service-details.facade.ts @@ -20,6 +20,8 @@ import { ServiceDateTime } from '../model/checkout-service-details.model'; 'setServiceScheduleSlot', 'getSelectedServiceDetailsState', 'getServiceProducts', + 'hasServiceItems', + 'hasNonServiceItems', ], }), }) @@ -42,4 +44,14 @@ export abstract class CheckoutServiceDetailsFacade { * Get the name of products of type SERVICE in the active cart */ abstract getServiceProducts(): Observable; + + /** + * Return whether cart has service products + */ + abstract hasServiceItems(): Observable; + + /** + * Return whether cart has deliverable physical products + */ + abstract hasNonServiceItems(): Observable; } diff --git a/integration-libs/s4-service/root/model/augmented-types.model.ts b/integration-libs/s4-service/root/model/augmented-types.model.ts index b6a04620155..f8512eb5aef 100644 --- a/integration-libs/s4-service/root/model/augmented-types.model.ts +++ b/integration-libs/s4-service/root/model/augmented-types.model.ts @@ -5,12 +5,14 @@ */ import '@spartacus/checkout/base/root'; -import { OccEndpoint } from '@spartacus/core'; +import { Config, OccEndpoint } from '@spartacus/core'; import '@spartacus/order/root'; import { serviceCancellable, ServiceDateTime, } from './checkout-service-details.model'; +import { Injectable } from '@angular/core'; +import { DeliveryMode } from '@spartacus/cart/base/root'; export abstract class ServiceOrderConfiguration { serviceOrderConfiguration?: { @@ -50,3 +52,15 @@ declare module '@spartacus/core' { productTypes?: string; } } + +@Injectable({ + providedIn: 'root', + useExisting: Config, +}) +export abstract class S4ServiceDeliveryModeConfig { + s4ServiceDeliveryMode?: DeliveryMode; +} + +declare module '@spartacus/core' { + export interface Config extends S4ServiceDeliveryModeConfig {} +} diff --git a/integration-libs/s4-service/root/model/index.ts b/integration-libs/s4-service/root/model/index.ts index c596bd9c15a..843212aa988 100644 --- a/integration-libs/s4-service/root/model/index.ts +++ b/integration-libs/s4-service/root/model/index.ts @@ -6,3 +6,4 @@ import './augmented-types.model'; export * from './checkout-service-details.model'; +export * from './augmented-types.model'; diff --git a/integration-libs/s4-service/root/s4-service-root.module.ts b/integration-libs/s4-service/root/s4-service-root.module.ts index 1b3ceb0359c..2ad163e0852 100644 --- a/integration-libs/s4-service/root/s4-service-root.module.ts +++ b/integration-libs/s4-service/root/s4-service-root.module.ts @@ -25,6 +25,7 @@ import { CheckoutServiceSchedulePickerService } from './facade/index'; import { ORDER_FEATURE } from '@spartacus/order/root'; import { RouterModule } from '@angular/router'; import { CmsPageGuard, PageLayoutComponent } from '@spartacus/storefront'; +import { defaultServiceDeliveryModeConfig } from './config/default-service-delivery-mode-config'; export const S4_SERVICE_CMS_COMPONENTS: string[] = [ ...CHECKOUT_B2B_CMS_COMPONENTS, @@ -106,6 +107,7 @@ export function defaultS4ServiceComponentsConfig() { providers: [ { provide: CheckoutConfig, useValue: defaultServiceDetailsCheckoutConfig }, provideDefaultConfig(defaultServiceOrdersRoutingConfig), + provideDefaultConfig(defaultServiceDeliveryModeConfig), provideDefaultConfigFactory(defaultS4ServiceComponentsConfig), CxDatePipe, CheckoutServiceSchedulePickerService, diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/vendor/s4-service/service-order-checkout-e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/vendor/s4-service/service-order-checkout-e2e.cy.ts index 541a3dda1b5..cc858cbc9aa 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/e2e/vendor/s4-service/service-order-checkout-e2e.cy.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/vendor/s4-service/service-order-checkout-e2e.cy.ts @@ -6,18 +6,7 @@ import { placeOrder } from '../../../helpers/b2b/b2b-checkout'; import { loginUser, signOutUser } from '../../../helpers/checkout-flow'; -import { - checkoutForServiceOrder, - nonServiceProduct, - selectAccountDeliveryModeForServiceOrder, - selectAccountPaymentForServiceOrder, - selectAccountShippingAddressForServiceOrder, - selectServiceDetailsForServiceOrder, - serviceProduct, - serviceUser, - verifyServiceOrderConfirmationPage, - verifyServiceOrderReviewOrderPage, -} from '../../../helpers/vendor/s4-service/s4-service'; +import * as helper from '../../../helpers/vendor/s4-service/s4-service'; import { POWERTOOLS_BASESITE } from '../../../sample-data/b2b-checkout'; describe('Service Order Checkout Flow ', () => { @@ -25,27 +14,40 @@ describe('Service Order Checkout Flow ', () => { cy.restoreLocalStorage(); Cypress.env('BASE_SITE', POWERTOOLS_BASESITE); cy.visit('/powertools-spa/en/USD/login'); - loginUser(serviceUser); + loginUser(helper.serviceUser); cy.get('button').contains('Allow All').click(); }); - it('with service products in cart', () => { - checkoutForServiceOrder(serviceProduct); - selectAccountPaymentForServiceOrder(); - selectAccountShippingAddressForServiceOrder(); - selectAccountDeliveryModeForServiceOrder(); - selectServiceDetailsForServiceOrder(); - verifyServiceOrderReviewOrderPage(true); + it('with only service products in cart', () => { + helper.addProductToCart(helper.serviceProduct); + helper.proceedToCheckout(); + helper.selectAccountPayment(); + helper.selectShippingAddress(false); + helper.selectServiceDetails(); + helper.verifyOrderReviewPage(true, false); placeOrder('/order-confirmation'); - verifyServiceOrderConfirmationPage(true); + helper.verifyOrderConfirmationPage(true, false); + }); + it('with both service and physical products in cart', () => { + helper.addProductToCart(helper.serviceProduct); + helper.addProductToCart(helper.nonServiceProduct); + helper.proceedToCheckout(); + helper.selectAccountPayment(); + helper.selectShippingAddress(true); + helper.selectDeliveryMode(true); + helper.selectServiceDetails(); + helper.verifyOrderReviewPage(true, true); + placeOrder('/order-confirmation'); + helper.verifyOrderConfirmationPage(true, true); }); it('without any service products in cart', () => { - checkoutForServiceOrder(nonServiceProduct); - selectAccountPaymentForServiceOrder(); - selectAccountShippingAddressForServiceOrder(); - selectAccountDeliveryModeForServiceOrder(); - verifyServiceOrderReviewOrderPage(false); + helper.addProductToCart(helper.nonServiceProduct); + helper.proceedToCheckout(); + helper.selectAccountPayment(); + helper.selectShippingAddress(true); + helper.selectDeliveryMode(false); + helper.verifyOrderReviewPage(false, true); placeOrder('/order-confirmation'); - verifyServiceOrderConfirmationPage(false); + helper.verifyOrderConfirmationPage(false, true); }); afterEach(() => { signOutUser(); diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/vendor/s4-service/s4-service.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/vendor/s4-service/s4-service.ts index 2856b1246c3..86fb5216282 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/vendor/s4-service/s4-service.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/vendor/s4-service/s4-service.ts @@ -20,7 +20,6 @@ import { waitForProductPage, addCheapProductToCart, waitForPage, - verifyReviewOrderPage, } from '../../checkout-flow'; import { tabbingOrderConfig as config } from '../../../helpers/accessibility/b2b/tabbing-order.config'; import { ORDER_CODE } from '../../../sample-data/service-order'; @@ -43,19 +42,18 @@ export const nonServiceProduct: SampleProduct = { code: '3887119', }; -export function checkoutForServiceOrder(product: SampleProduct) { +export function addProductToCart(product: SampleProduct) { const productPage = waitForProductPage(product.code, 'getProductPage'); - const getPaymentTypes = interceptPaymentTypesEndpoint(); - cy.visit(`${POWERTOOLS_BASESITE}/en/USD/product/${product.code}`); cy.wait(`@${productPage}`).its('response.statusCode').should('eq', 200); - cy.get('cx-product-intro').within(() => { cy.get('.code').should('contain', product.code); }); - addCheapProductToCart(product); +} +export function proceedToCheckout() { + const getPaymentTypes = interceptPaymentTypesEndpoint(); const paymentTypePage = waitForPage( '/checkout/payment-type', 'getPaymentType' @@ -65,25 +63,27 @@ export function checkoutForServiceOrder(product: SampleProduct) { cy.wait(`@${getPaymentTypes}`).its('response.statusCode').should('eq', 200); } -export function selectAccountDeliveryModeForServiceOrder() { - const getCheckoutDetails = interceptCheckoutB2BDetailsEndpoint(); - +export function selectDeliveryMode(checkServiceDetails: boolean) { + const getCheckoutDetailsAfterDeliveryMode = + interceptCheckoutB2BDetailsEndpoint(); + const serviceDetails = waitForPage( + '/checkout/service-details', + 'getServiceDetails' + ); cy.get('.cx-checkout-title').should('contain', 'Delivery Options'); cy.get('cx-delivery-mode input').first().should('be.checked'); cy.get('.cx-checkout-btns button.btn-primary') .should('be.enabled') .click({ force: true }); - cy.wait(`@${getCheckoutDetails}`) + cy.wait(`@${getCheckoutDetailsAfterDeliveryMode}`) .its('response.statusCode') .should('eq', 200); - const serviceDetails = waitForPage( - '/checkout/service-details', - 'getServiceDetails' - ); - cy.wait(`@${serviceDetails}`, { timeout: 30000 }) - .its('response.statusCode') - .should('eq', 200); + if (checkServiceDetails) { + cy.wait(`@${serviceDetails}`, { timeout: 30000 }) + .its('response.statusCode') + .should('eq', 200); + } } export function verifyServiceDatePickerExists() { @@ -101,7 +101,7 @@ export function interceptPatchServiceDetailsEndpoint() { return alias; } -export function selectServiceDetailsForServiceOrder() { +export function selectServiceDetails() { const patchServiceDetails = interceptPatchServiceDetailsEndpoint(); const orderReview = waitForPage('/checkout/review-order', 'getReviewOrder'); const getCheckoutDetails = interceptCheckoutB2BDetailsEndpoint(); @@ -130,30 +130,43 @@ export function selectServiceDetailsForServiceOrder() { .should('eq', 200); } -export function verifyServiceOrderReviewOrderPage(serviceOrder: boolean) { - verifyReviewOrderPage(); - - if (serviceOrder) { +export function verifyOrderReviewPage( + shouldServiceDetailsBeVisible: boolean, + shouldDeliveryModeBeVisible: boolean +) { + if (shouldServiceDetailsBeVisible) { cy.get('.cx-review-summary-card') .contains('cx-card', 'Service Details') .find('.cx-card-container') - .within(() => { - cy.findByText('Scheduled At'); - }); + .should('exist'); cy.get('.cx-review-summary-card') .contains('cx-card', 'Service Details') .find('.cx-card-container .cx-card-label') .should((div) => { const text = div.text().trim(); - expect(text).to.match(/\d{2}\/\d{2}\/\d{4}, \d{2}:\d{2}:\d{2}/); + expect(text).to.match(/\d{2}:\d{2}:\d{2}/); + }); + cy.get('.cx-review-summary-card') + .contains('cx-card', 'Service Details') + .find('.cx-card-container .cx-card-label-bold') + .should((div) => { + const text = div.text().trim(); + expect(text).to.match(/^\d{2}\/\d{2}\/\d{4}$/); }); } else { cy.get('.cx-review-summary-card') .contains('cx-card', 'Service Details') - .find('.cx-card-container') .should('not.exist'); } + if (shouldDeliveryModeBeVisible === true) { + cy.contains('.cx-review-summary-card', 'Delivery Method').should('exist'); + } else { + cy.contains('.cx-review-summary-card', 'Delivery Method').should( + 'not.exist' + ); + } + cy.findByText('Terms & Conditions') .should('have.attr', 'target', '_blank') .should( @@ -164,27 +177,42 @@ export function verifyServiceOrderReviewOrderPage(serviceOrder: boolean) { cy.get('input[formcontrolname="termsAndConditions"]').check(); } -export function verifyServiceOrderConfirmationPage(serviceOrder: boolean) { - if (serviceOrder) { +export function verifyOrderConfirmationPage( + shouldServiceDetailsBeVisible: boolean, + shouldDeliveryModeBeVisible: boolean +) { + if (shouldServiceDetailsBeVisible) { cy.get('cx-card-service-details') .contains('cx-card', 'Service Details') .find('.cx-card-container') - .within(() => { - cy.findByText('Scheduled At'); + .should('exist'); + cy.get('cx-card-service-details') + .contains('cx-card', 'Service Details') + .find('.cx-card-container .cx-card-label') + .should((div) => { + const text = div.text().trim(); + expect(text).to.match(/\d{2}:\d{2}:\d{2}/); + }); + cy.get('cx-card-service-details') + .contains('cx-card', 'Service Details') + .find('.cx-card-container .cx-card-label-bold') + .should((div) => { + const text = div.text().trim(); + expect(text).to.match(/^\d{2}\/\d{2}\/\d{4}$/); }); - cy.get('cx-card-service-details .cx-card-label').should((div) => { - const text = div.text().trim(); - expect(text).to.match(/\d{2}\/\d{2}\/\d{4}, \d{2}:\d{2}:\d{2}/); - }); } else { cy.get('cx-card-service-details') .contains('cx-card', 'Service Details') - .find('.cx-card-container') .should('not.exist'); } + if (shouldDeliveryModeBeVisible === true) { + cy.contains('cx-card', 'Shipping Method').should('exist'); + } else { + cy.contains('cx-card', 'Shipping Method').should('not.exist'); + } } -export function selectAccountPaymentForServiceOrder() { +export function selectAccountPayment() { const getCostCenters = interceptCostCenterEndpoint(); cy.get('cx-payment-type').within(() => { @@ -215,7 +243,7 @@ export function selectAccountPaymentForServiceOrder() { }); } -export function selectAccountShippingAddressForServiceOrder() { +export function selectShippingAddress(checkDeliveryMode: boolean) { const putDeliveryMode = interceptPutDeliveryModeEndpoint(); cy.get('.cx-checkout-title').should('contain', 'Shipping Address'); @@ -241,8 +269,10 @@ export function selectAccountShippingAddressForServiceOrder() { ); cy.get('button.btn-primary').should('be.enabled').click(); - cy.wait(`@${deliveryPage}`).its('response.statusCode').should('eq', 200); - cy.wait(`@${putDeliveryMode}`).its('response.statusCode').should('eq', 200); + if (checkDeliveryMode === true) { + cy.wait(`@${deliveryPage}`).its('response.statusCode').should('eq', 200); + cy.wait(`@${putDeliveryMode}`).its('response.statusCode').should('eq', 200); + } } export function interceptOrderList(alias, response) {