diff --git a/feature-libs/cart/quick-order/assets/translations/en/quick-order.i18n.ts b/feature-libs/cart/quick-order/assets/translations/en/quick-order.i18n.ts index 93a41778be7..badf73aa798 100644 --- a/feature-libs/cart/quick-order/assets/translations/en/quick-order.i18n.ts +++ b/feature-libs/cart/quick-order/assets/translations/en/quick-order.i18n.ts @@ -1,3 +1,21 @@ +export const quickOrderCartForm = { + entriesWasAdded: '({{ quantity }}) {{ product }} has been added to the cart', + entryWasAdded: '{{ product }} has been added to the cart', + noResults: 'We could not find any products', + stockLevelReached: 'The maximum stock level has been reached', + title: 'Quick Order', + productCode: 'Product Code', + addToCart: 'Add To Cart', + product: 'Product', + products: 'Products', +}; + +export const quickOrderContainer = { + addProducts: 'Add Products/Skus', + emptyList: 'Empty list', + addToCart: 'Add to cart', +}; + export const quickOrderForm = { placeholder: 'Enter Product SKU', }; @@ -14,13 +32,8 @@ export const quickOrderList = { outOfStock: 'Out of Stock', }; -export const quickOrderContainer = { - addProducts: 'Add Products/Skus', - emptyList: 'Empty list', - addToCart: 'Add to cart', -}; - export const quickOrder = { + quickOrderCartForm, quickOrderContainer, quickOrderForm, quickOrderList, diff --git a/feature-libs/cart/quick-order/assets/translations/translations.ts b/feature-libs/cart/quick-order/assets/translations/translations.ts index 18a04c28b6e..015b2473084 100644 --- a/feature-libs/cart/quick-order/assets/translations/translations.ts +++ b/feature-libs/cart/quick-order/assets/translations/translations.ts @@ -7,5 +7,10 @@ export const quickOrderTranslations: TranslationResources = { // expose all translation chunk mapping for quickOrder feature export const quickOrderTranslationChunksConfig: TranslationChunksConfig = { - quickOrder: ['quickOrderContainer', 'quickOrderForm', 'quickOrderList'], + quickOrder: [ + 'quickOrderCartForm', + 'quickOrderContainer', + 'quickOrderForm', + 'quickOrderList', + ], }; diff --git a/feature-libs/cart/quick-order/components/cart-quick-form/cart-quick-form.component.html b/feature-libs/cart/quick-order/components/cart-quick-form/cart-quick-form.component.html new file mode 100644 index 00000000000..6a0128887a5 --- /dev/null +++ b/feature-libs/cart/quick-order/components/cart-quick-form/cart-quick-form.component.html @@ -0,0 +1,34 @@ + +
+ {{ 'quickOrderCartForm.title' | cxTranslate }} +
+
+
+
+ + + + + +
+
diff --git a/feature-libs/cart/quick-order/components/cart-quick-form/cart-quick-form.component.spec.ts b/feature-libs/cart/quick-order/components/cart-quick-form/cart-quick-form.component.spec.ts new file mode 100644 index 00000000000..ddb62247457 --- /dev/null +++ b/feature-libs/cart/quick-order/components/cart-quick-form/cart-quick-form.component.spec.ts @@ -0,0 +1,175 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { StoreModule } from '@ngrx/store'; +import { + ActiveCartService, + Cart, + CartAddEntryFailEvent, + CartAddEntrySuccessEvent, + EventService, + GlobalMessageService, + GlobalMessageType, + I18nTestingModule, + Translatable, +} from '@spartacus/core'; +import { FormErrorsModule } from 'projects/storefrontlib/src/shared'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { CartQuickFormComponent } from './cart-quick-form.component'; + +const mockCart: Cart = { + code: '123456789', + description: 'testCartDescription', + name: 'testCartName', +}; + +const mockUserId = 'test-user'; +const mockCartId = '123456789'; + +const mockCartAddEntryFailEvent: CartAddEntryFailEvent = { + cartCode: mockCartId, + cartId: mockCartId, + productCode: '123456789', + quantity: 1, + userId: mockUserId, +}; +const mockCartAddEntrySuccessEvent: CartAddEntrySuccessEvent = { + cartCode: mockCartId, + cartId: mockCartId, + deliveryModeChanged: false, + entry: { + product: { + name: 'test-product', + }, + }, + productCode: '123456789', + quantity: 1, + quantityAdded: 1, + userId: mockUserId, +}; + +const cart$ = new BehaviorSubject(mockCart); + +class MockEventService implements Partial { + get(): Observable { + return of(); + } +} + +class MockGlobalMessageService implements Partial { + add( + _text: string | Translatable, + _type: GlobalMessageType, + _timeout?: number + ): void {} +} + +class MockActiveCartService implements Partial { + getActive(): Observable { + return cart$.asObservable(); + } + getActiveCartId(): Observable { + return of('123456789'); + } + isStable(): Observable { + return of(true); + } + addEntry(_productCode: string, _quantity: number): void {} +} + +fdescribe('CartQuickFormComponent', () => { + let component: CartQuickFormComponent; + let fixture: ComponentFixture; + let activeCartService: ActiveCartService; + let eventService: EventService; + let globalMessageService: GlobalMessageService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + FormErrorsModule, + I18nTestingModule, + ReactiveFormsModule, + StoreModule.forRoot({}), + ], + declarations: [CartQuickFormComponent], + providers: [ + { provide: ActiveCartService, useClass: MockActiveCartService }, + { + provide: EventService, + useClass: MockEventService, + }, + { provide: GlobalMessageService, useClass: MockGlobalMessageService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CartQuickFormComponent); + component = fixture.componentInstance; + + activeCartService = TestBed.inject(ActiveCartService); + eventService = TestBed.inject(EventService); + globalMessageService = TestBed.inject(GlobalMessageService); + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create form on init', () => { + expect(component.orderForm.valid).toBeFalsy(); + expect(component.orderForm.controls['productCode'].value).toBe(''); + expect(component.orderForm.controls['quantity'].value).toBe(1); + }); + + it('should add entry on form submit', () => { + spyOn(activeCartService, 'addEntry').and.callThrough(); + + component.orderForm.controls['productCode'].setValue('test'); + component.applyQuickOrder(); + + expect(activeCartService.addEntry).toHaveBeenCalledWith('test', 1); + }); + + it('should set quantity value to min when it is smaller than min value', () => { + component.min = 3; + component.orderForm.controls['quantity'].setValue(2); + fixture.detectChanges(); + + expect(component.orderForm.controls['quantity'].value).toEqual(3); + }); + + it('should show global confirmation message on add entry success event', () => { + spyOn(globalMessageService, 'add').and.callThrough(); + spyOn(eventService, 'get').and.returnValue( + of(mockCartAddEntrySuccessEvent) + ); + + component.ngOnInit(); + + expect(globalMessageService.add).toHaveBeenCalledWith( + { + key: 'quickOrderCartForm.entryWasAdded', + params: { + product: mockCartAddEntrySuccessEvent.entry.product?.name, + quantity: mockCartAddEntrySuccessEvent.quantityAdded, + }, + }, + GlobalMessageType.MSG_TYPE_CONFIRMATION + ); + }); + + it('should show global error message on add entry fail event', () => { + spyOn(globalMessageService, 'add').and.callThrough(); + spyOn(eventService, 'get').and.returnValue(of(mockCartAddEntryFailEvent)); + + component.ngOnInit(); + + expect(globalMessageService.add).toHaveBeenCalledWith( + { + key: 'quickOrderCartForm.noResults', + }, + GlobalMessageType.MSG_TYPE_ERROR + ); + }); +}); diff --git a/feature-libs/cart/quick-order/components/cart-quick-form/cart-quick-form.component.ts b/feature-libs/cart/quick-order/components/cart-quick-form/cart-quick-form.component.ts new file mode 100644 index 00000000000..1c98804cbff --- /dev/null +++ b/feature-libs/cart/quick-order/components/cart-quick-form/cart-quick-form.component.ts @@ -0,0 +1,151 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { + ActiveCartService, + Cart, + CartAddEntryFailEvent, + CartAddEntrySuccessEvent, + EventService, + GlobalMessageService, + GlobalMessageType, +} from '@spartacus/core'; +import { Observable, Subscription } from 'rxjs'; +import { map, switchMap, tap } from 'rxjs/operators'; + +@Component({ + selector: 'cx-cart-quick-form', + templateUrl: './cart-quick-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CartQuickFormComponent implements OnInit { + orderForm: FormGroup; + cartIsLoading$: Observable; + cart$: Observable; + cartId: string; + min = 1; + /** + * Subscription responsible for auto-correcting control's value when it's invalid. + */ + private subscription: Subscription = new Subscription(); + + constructor( + protected activeCartService: ActiveCartService, + protected eventService: EventService, + protected formBuilder: FormBuilder, + protected globalMessageService: GlobalMessageService + ) {} + + ngOnInit(): void { + this.cart$ = this.activeCartService.getActiveCartId().pipe( + tap((activeCardId: string) => (this.cartId = activeCardId)), + switchMap(() => this.activeCartService.getActive()) + ); + + this.cartIsLoading$ = this.activeCartService + .isStable() + .pipe(map((loaded) => !loaded)); + + this.buildForm(); + this.watchQuantityChange(); + this.watchAddEntrySuccessEvent(); + this.watchAddEntryFailEvent(); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + + applyQuickOrder(): void { + if (this.orderForm.invalid) { + return; + } + const productCode = this.orderForm.get('productCode')?.value; + const quantity = this.orderForm.get('quantity')?.value; + + if (productCode && quantity) { + this.activeCartService.addEntry(productCode, quantity); + } + } + + protected buildForm(): void { + this.orderForm = this.formBuilder.group({ + productCode: ['', [Validators.required]], + quantity: [1, [Validators.required]], + }); + } + + protected watchQuantityChange(): void { + this.subscription.add( + this.orderForm + .get('quantity') + ?.valueChanges.subscribe((value) => + this.orderForm + .get('quantity') + ?.setValue(this.getValidCount(value), { emitEvent: false }) + ) + ); + } + + protected watchAddEntrySuccessEvent(): void { + this.subscription.add( + this.eventService + .get(CartAddEntrySuccessEvent) + .subscribe((data: CartAddEntrySuccessEvent) => { + let key = 'quickOrderCartForm.stockLevelReached'; + let productTranslation; + let messageType = GlobalMessageType.MSG_TYPE_WARNING; + + if (data.quantityAdded && 0 < data.quantityAdded) { + key = + data.quantityAdded > 1 + ? 'quickOrderCartForm.entriesWasAdded' + : 'quickOrderCartForm.entryWasAdded'; + + productTranslation = + data.quantityAdded > 1 + ? 'quickOrderCartForm.products' + : 'quickOrderCartForm.product'; + + messageType = GlobalMessageType.MSG_TYPE_CONFIRMATION; + } + + this.globalMessageService.add( + { + key, + params: { + product: data?.entry?.product?.name || productTranslation, + quantity: data.quantityAdded, + }, + }, + messageType + ); + this.resetForm(); + }) + ); + } + + protected watchAddEntryFailEvent(): void { + this.subscription.add( + this.eventService.get(CartAddEntryFailEvent).subscribe(() => { + this.globalMessageService.add( + { + key: 'quickOrderCartForm.noResults', + }, + GlobalMessageType.MSG_TYPE_ERROR + ); + }) + ); + } + + private getValidCount(value: number) { + if (value < this.min || !value) { + value = this.min; + } + + return value; + } + + protected resetForm(): void { + this.orderForm.reset(); + } +} diff --git a/feature-libs/cart/quick-order/components/cart-quick-form/cart-quick-form.module.ts b/feature-libs/cart/quick-order/components/cart-quick-form/cart-quick-form.module.ts new file mode 100644 index 00000000000..7d6d03f8928 --- /dev/null +++ b/feature-libs/cart/quick-order/components/cart-quick-form/cart-quick-form.module.ts @@ -0,0 +1,25 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { CmsConfig, provideDefaultConfig } from '@spartacus/core'; +import { FormErrorsModule } from '@spartacus/storefront'; +import { I18nModule } from 'projects/core/src/i18n'; +import { CartQuickFormComponent } from './cart-quick-form.component'; + +@NgModule({ + imports: [CommonModule, ReactiveFormsModule, I18nModule, FormErrorsModule], + providers: [ + provideDefaultConfig({ + cmsComponents: { + // TODO replace once new sample data will be ready + CartApplyCouponComponent: { + component: CartQuickFormComponent, + }, + }, + }), + ], + declarations: [CartQuickFormComponent, CartQuickFormComponent], + exports: [CartQuickFormComponent], + entryComponents: [CartQuickFormComponent], +}) +export class CartQuickFormModule {} diff --git a/feature-libs/cart/quick-order/components/cart-quick-form/index.ts b/feature-libs/cart/quick-order/components/cart-quick-form/index.ts new file mode 100644 index 00000000000..6ebc919677b --- /dev/null +++ b/feature-libs/cart/quick-order/components/cart-quick-form/index.ts @@ -0,0 +1,2 @@ +export * from './cart-quick-form.component'; +export * from './cart-quick-form.module'; diff --git a/feature-libs/cart/quick-order/components/list/quick-order-list.component.ts b/feature-libs/cart/quick-order/components/list/quick-order-list.component.ts index 01f6cd4e1fd..14777c8c28f 100644 --- a/feature-libs/cart/quick-order/components/list/quick-order-list.component.ts +++ b/feature-libs/cart/quick-order/components/list/quick-order-list.component.ts @@ -1,83 +1,12 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { ImageType, OrderEntry } from 'projects/core/src/model'; -import { Observable, of } from 'rxjs'; +import { OrderEntry } from 'projects/core/src/model'; +import { Observable } from 'rxjs'; -// TO remove in future -const mockEntries: any[] = [ - { - basePrice: { - formattedValue: '$60.00', - value: 60.0, - }, - entryNumber: 1, - product: { - availableForPickup: false, - code: '2116283', - images: { - PRIMARY: { - cart: { - altText: 'Test alt text', - format: 'cart', - imageType: ImageType.PRIMARY, - url: - 'https://spartacus-dev4.eastus.cloudapp.azure.com:9002//medias/?context=bWFzdGVyfGltYWdlc3wxMjQwfGltYWdlL2pwZWd8aW1hZ2VzL2gyYS9oYTMvODc5Njk0NDU2NDI1NC5qcGd8ODI3NjA2NjhiOTNiNDYxOTMxMzdiNjIxMmFmNjFiZGUxMWVkMmQ5ZDA3ZGU4YmU2Mzg0MzcxNDRjMDJmNzdjMQ', - }, - }, - }, - name: 'Product title', - summary: 'Product summary', - manufacturer: 'Black & Decker', - purchasable: true, - stock: { - stockLevel: 353, - stockLevelStatus: 'inStock', - }, - url: '/Open-Catalogue/Tools/Sanders/KA86/p/2116283', - }, - quantity: 2, - returnableQuantity: 0, - totalPrice: { - currencyIso: 'USD', - formattedValue: '$120.00', - value: 120.0, - }, - updateable: true, - }, - { - basePrice: { - formattedValue: '$60.00', - value: 60.0, - }, - entryNumber: 2, - product: { - availableForPickup: false, - code: '2116283', - configurable: false, - name: 'fdsfsd fsdfs', - summary: 'Product summary', - manufacturer: 'Black & Decker', - purchasable: true, - stock: { - stockLevel: 353, - stockLevelStatus: 'inStock', - }, - url: '/Open-Catalogue/Tools/Sanders/KA86/p/2116283', - }, - quantity: 2, - returnableQuantity: 0, - totalPrice: { - currencyIso: 'USD', - formattedValue: '$120.00', - value: 120.0, - }, - updateable: true, - }, -]; @Component({ selector: 'cx-quick-order-list', templateUrl: './quick-order-list.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class QuickOrderListComponent { - entries$: Observable = of(mockEntries); + entries$: Observable; } diff --git a/feature-libs/cart/quick-order/components/quick-order-components.module.ts b/feature-libs/cart/quick-order/components/quick-order-components.module.ts index e7d9ae6d467..1f25a1fd321 100644 --- a/feature-libs/cart/quick-order/components/quick-order-components.module.ts +++ b/feature-libs/cart/quick-order/components/quick-order-components.module.ts @@ -1,8 +1,9 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; +import { CartQuickFormModule } from './cart-quick-form/cart-quick-form.module'; import { QuickOrderContainerModule } from './container/quick-order-container.module'; @NgModule({ - imports: [RouterModule, QuickOrderContainerModule], + imports: [RouterModule, QuickOrderContainerModule, CartQuickFormModule], }) export class QuickOrderComponentsModule {} diff --git a/feature-libs/cart/quick-order/root/quick-order-root.module.ts b/feature-libs/cart/quick-order/root/quick-order-root.module.ts index 7c634a1df9f..a489ea17d3c 100644 --- a/feature-libs/cart/quick-order/root/quick-order-root.module.ts +++ b/feature-libs/cart/quick-order/root/quick-order-root.module.ts @@ -7,7 +7,11 @@ import { provideDefaultConfig, RoutingConfig } from '@spartacus/core'; provideDefaultConfig({ featureModules: { cartQuickOrder: { - cmsComponents: ['QuickOrderComponent'], + cmsComponents: [ + 'QuickOrderComponent', + // TODO replace once new sample data will be ready + 'CartApplyCouponComponent', + ], }, }, }), diff --git a/feature-libs/cart/quick-order/styles/_cart-quick-order.scss b/feature-libs/cart/quick-order/styles/_cart-quick-order.scss new file mode 100644 index 00000000000..e3ee8224a33 --- /dev/null +++ b/feature-libs/cart/quick-order/styles/_cart-quick-order.scss @@ -0,0 +1,54 @@ +cx-cart-quick-form { + padding-bottom: 2rem; + padding-inline-end: 0; + padding-inline-start: 3rem; + padding-top: 2rem; + + @include media-breakpoint-down(md) { + padding-bottom: 2rem; + padding-inline-end: 3rem; + padding-inline-start: 3rem; + padding-top: 0; + } + + .cx-cart-quick-form-title { + @include type('5'); + margin: 1.125rem 0; + } + + .cx-cart-quick-form-container { + display: grid; + grid-column-gap: 5px; + grid-row-gap: 10px; + grid-template-columns: auto; + grid-template-rows: auto; + + .input-product-code { + grid-area: 1 / 1 / 2 / 4; + } + + .input-quantity { + grid-area: 1 / 4 / 2 / 5; + text-align: center; + } + + cx-form-errors { + grid-area: 2 / 1 / 3 / 5; + } + + button { + grid-area: 3 / 1 / 3 / 5; + } + + input { + // avoid native increase/decrease buttons on the numeric field + &[type='number']::-webkit-inner-spin-button, + &[type='number']::-webkit-outer-spin-button { + appearance: none; + } + &[type='number'] { + -moz-appearance: textfield; + } + } + } +} diff --git a/feature-libs/cart/quick-order/styles/_index.scss b/feature-libs/cart/quick-order/styles/_index.scss index b1ce1e44421..3d490e72fb5 100644 --- a/feature-libs/cart/quick-order/styles/_index.scss +++ b/feature-libs/cart/quick-order/styles/_index.scss @@ -1,3 +1,4 @@ +@import './cart-quick-order'; +@import './quick-order-container'; @import './quick-order-form'; @import './quick-order-list'; -@import './quick-order-container'; diff --git a/projects/storefrontapp/src/environments/environment.ts b/projects/storefrontapp/src/environments/environment.ts index fc2c6f817c4..feb5eab58f0 100644 --- a/projects/storefrontapp/src/environments/environment.ts +++ b/projects/storefrontapp/src/environments/environment.ts @@ -15,7 +15,7 @@ import { Environment } from './models/environment.model'; export const environment: Environment = { production: false, - occBaseUrl: 'https://spartacus-dev4.eastus.cloudapp.azure.com:9002/', + occBaseUrl: 'https://spartacus-dev3.eastus.cloudapp.azure.com:9002/', // occBaseUrl: buildProcess.env.CX_BASE_URL, occApiPrefix: '/occ/v2/', cds: buildProcess.env.CX_CDS ?? false, diff --git a/projects/storefrontlib/src/cms-components/cart/cart-coupon/cart-coupon.module.ts b/projects/storefrontlib/src/cms-components/cart/cart-coupon/cart-coupon.module.ts index 6fa9a2b318f..b3a576c0def 100644 --- a/projects/storefrontlib/src/cms-components/cart/cart-coupon/cart-coupon.module.ts +++ b/projects/storefrontlib/src/cms-components/cart/cart-coupon/cart-coupon.module.ts @@ -1,7 +1,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { CmsConfig, I18nModule, provideDefaultConfig } from '@spartacus/core'; +import { I18nModule } from '@spartacus/core'; import { IconModule } from '../../../cms-components/misc/icon/icon.module'; import { AppliedCouponsComponent } from './applied-coupons/applied-coupons.component'; import { CartCouponComponent } from './cart-coupon.component'; @@ -20,14 +20,15 @@ import { FormErrorsModule } from '../../../shared/index'; IconModule, FormErrorsModule, ], - providers: [ - provideDefaultConfig({ - cmsComponents: { - CartApplyCouponComponent: { - component: CartCouponComponent, - }, - }, - }), - ], + // TODO replace once new sample data will be ready + // providers: [ + // provideDefaultConfig({ + // cmsComponents: { + // CartApplyCouponComponent: { + // component: CartCouponComponent, + // }, + // }, + // }), + // ], }) export class CartCouponModule {}