From 81341ce201ae3cf8b67d23b8cf3bb3a8b8b973bd Mon Sep 17 00:00:00 2001 From: Danilo Hoffmann Date: Tue, 1 Feb 2022 09:50:28 +0100 Subject: [PATCH] fix: respect ICM setting basket.maxItemQuantity (#993) * docs: add migration hint Co-authored-by: Silke --- docs/guides/migrations.md | 4 ++ .../facades/product-context.facade.spec.ts | 14 ++++++- .../core/facades/product-context.facade.ts | 37 +++++++++++++------ .../selected-product-context.facade.ts | 12 +++--- .../__snapshots__/product.mapper.spec.ts.snap | 4 +- src/app/core/models/product/product.mapper.ts | 20 +++------- 6 files changed, 56 insertions(+), 35 deletions(-) diff --git a/docs/guides/migrations.md b/docs/guides/migrations.md index 959cf8b1b1..ee21507213 100644 --- a/docs/guides/migrations.md +++ b/docs/guides/migrations.md @@ -41,6 +41,10 @@ The feature toggle 'advancedVariationHandling' has been removed. Instead the ICM channel preference 'AdvancedVariationHandling' is used to configure it. You will find this preference as 'List View' in the ICM backoffice under Channel Preferences -> Product Variations. +The ICM channel preference 'basket.maxItemQuantity' is included to validate the product quantity if no specific setting is defined on the product. +You find this preference as 'Maximum Quantity per Product in Cart' under the Application Settings -> Shopping Cart & Checkout. +The default value is 100. + ## 1.1 to 1.2 The `dist` folder now only contains results of the build process (except for `healthcheck.js`). diff --git a/src/app/core/facades/product-context.facade.spec.ts b/src/app/core/facades/product-context.facade.spec.ts index c699ecdc5c..47cab413fb 100644 --- a/src/app/core/facades/product-context.facade.spec.ts +++ b/src/app/core/facades/product-context.facade.spec.ts @@ -12,6 +12,7 @@ import { Category } from 'ish-core/models/category/category.model'; import { ProductView } from 'ish-core/models/product-view/product-view.model'; import { ProductCompletenessLevel } from 'ish-core/models/product/product.model'; +import { AppFacade } from './app.facade'; import { EXTERNAL_DISPLAY_PROPERTY_PROVIDER, ExternalDisplayPropertiesProvider, @@ -39,9 +40,16 @@ describe('Product Context Facade', () => { when(shoppingFacade.productVariationCount$(anything())).thenReturn(of(undefined)); when(shoppingFacade.inCompareProducts$(anything())).thenReturn(of(false)); + const appFacade = mock(AppFacade); + when(appFacade.serverSetting$(anything())).thenReturn(of(undefined)); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], - providers: [ProductContextFacade, { provide: ShoppingFacade, useFactory: () => instance(shoppingFacade) }], + providers: [ + ProductContextFacade, + { provide: ShoppingFacade, useFactory: () => instance(shoppingFacade) }, + { provide: AppFacade, useFactory: () => instance(appFacade) }, + ], }); context = TestBed.inject(ProductContextFacade); @@ -746,11 +754,15 @@ describe('Product Context Facade', () => { when(shoppingFacade.product$(anyString(), anything())).thenCall(sku => of({ ...product, sku })); + const appFacade = mock(AppFacade); + when(appFacade.serverSetting$(anything())).thenReturn(of(undefined)); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], providers: [ ProductContextFacade, { provide: ShoppingFacade, useFactory: () => instance(shoppingFacade) }, + { provide: AppFacade, useFactory: () => instance(appFacade) }, { provide: EXTERNAL_DISPLAY_PROPERTY_PROVIDER, useClass: ProviderA, multi: true }, { provide: EXTERNAL_DISPLAY_PROPERTY_PROVIDER, useClass: ProviderB, multi: true }, { provide: EXTERNAL_DISPLAY_PROPERTY_PROVIDER, useClass: ProviderC, multi: true }, diff --git a/src/app/core/facades/product-context.facade.ts b/src/app/core/facades/product-context.facade.ts index 8d38d3591e..64cba7580c 100644 --- a/src/app/core/facades/product-context.facade.ts +++ b/src/app/core/facades/product-context.facade.ts @@ -16,6 +16,7 @@ import { generateProductUrl } from 'ish-core/routing/product/product.route'; import { mapToProperty, whenTruthy } from 'ish-core/utils/operators'; import { ProductContextDisplayPropertiesService } from 'ish-core/utils/product-context-display-properties/product-context-display-properties.service'; +import { AppFacade } from './app.facade'; import { ShoppingFacade } from './shopping.facade'; declare type DisplayEval = ((product: ProductView) => boolean) | boolean; @@ -106,7 +107,7 @@ export interface ProductContext { @Injectable() export class ProductContextFacade extends RxState { private privateConfig$ = new BehaviorSubject>({}); - private loggingActive = false; + private loggingActive: boolean; private lazyFieldsInitialized: string[] = []; set config(config: Partial) { @@ -118,7 +119,12 @@ export class ProductContextFacade extends RxState { mapToProperty('sku') ); - constructor(private shoppingFacade: ShoppingFacade, private translate: TranslateService, injector: Injector) { + constructor( + private shoppingFacade: ShoppingFacade, + private appFacade: AppFacade, + private translate: TranslateService, + injector: Injector + ) { super(); this.set({ @@ -172,30 +178,37 @@ export class ProductContextFacade extends RxState { this.connect( 'minQuantity', - combineLatest([this.select('product', 'minOrderQuantity'), this.select('allowZeroQuantity')]).pipe( - map(([minOrderQuantity, allowZeroQuantity]) => (allowZeroQuantity ? 0 : minOrderQuantity)) + combineLatest([this.select('product'), this.select('allowZeroQuantity')]).pipe( + map(([product, allowZeroQuantity]) => (allowZeroQuantity ? 0 : product.minOrderQuantity || 1)) ) ); - this.connect('maxQuantity', this.select('product', 'maxOrderQuantity')); - this.connect('stepQuantity', this.select('product', 'stepOrderQuantity')); + this.connect( + 'maxQuantity', + combineLatest([this.select('product'), this.appFacade.serverSetting$('basket.maxItemQuantity')]).pipe( + map(([product, fromConfig]) => product?.maxOrderQuantity || fromConfig || 100) + ) + ); + this.connect('stepQuantity', this.select('product').pipe(map(product => product?.stepOrderQuantity || 1))); this.connect( combineLatest([ this.select('product'), this.select('minQuantity'), + this.select('maxQuantity'), + this.select('stepQuantity'), this.select('quantity').pipe(distinctUntilChanged()), ]).pipe( - map(([product, minOrderQuantity, quantity]) => { + map(([product, minOrderQuantity, maxOrderQuantity, stepQuantity, quantity]) => { if (product && !product.failed) { if (Number.isNaN(quantity)) { return this.translate.instant('product.quantity.integer.text'); } else if (quantity < minOrderQuantity) { - return this.translate.instant('product.quantity.greaterthan.text', { 0: product.minOrderQuantity }); - } else if (quantity > product.maxOrderQuantity) { - return this.translate.instant('product.quantity.lessthan.text', { 0: product.maxOrderQuantity }); - } else if (quantity % product.stepOrderQuantity !== 0) { - return this.translate.instant('product.quantity.step.text', { 0: product.stepOrderQuantity }); + return this.translate.instant('product.quantity.greaterthan.text', { 0: minOrderQuantity }); + } else if (quantity > maxOrderQuantity) { + return this.translate.instant('product.quantity.lessthan.text', { 0: maxOrderQuantity }); + } else if (quantity % stepQuantity !== 0) { + return this.translate.instant('product.quantity.step.text', { 0: stepQuantity }); } } return; diff --git a/src/app/core/facades/selected-product-context.facade.ts b/src/app/core/facades/selected-product-context.facade.ts index 6b4587716a..0bea0d5dcc 100644 --- a/src/app/core/facades/selected-product-context.facade.ts +++ b/src/app/core/facades/selected-product-context.facade.ts @@ -15,10 +15,10 @@ export class SelectedProductContextFacade extends ProductContextFacade { shoppingFacade: ShoppingFacade, translate: TranslateService, injector: Injector, - private router: Router, - private appFacade: AppFacade + router: Router, + appFacade: AppFacade ) { - super(shoppingFacade, translate, injector); + super(shoppingFacade, appFacade, translate, injector); this.set('requiredCompletenessLevel', () => true); this.connect('categoryId', shoppingFacade.selectedCategoryId$); this.connect('sku', shoppingFacade.selectedProductId$); @@ -28,7 +28,7 @@ export class SelectedProductContextFacade extends ProductContextFacade { this.select('product').pipe( filter(ProductVariationHelper.hasDefaultVariation), concatMap(p => - this.appFacade.serverSetting$('preferences.ChannelPreferences.EnableAdvancedVariationHandling').pipe( + appFacade.serverSetting$('preferences.ChannelPreferences.EnableAdvancedVariationHandling').pipe( filter(advancedVariationHandling => advancedVariationHandling !== undefined && !advancedVariationHandling), map(() => p.defaultVariationSKU) ) @@ -39,10 +39,10 @@ export class SelectedProductContextFacade extends ProductContextFacade { this.hold( this.select('productURL').pipe( skip(1), - withLatestFrom(this.appFacade.routingInProgress$), + withLatestFrom(appFacade.routingInProgress$), filter(([, progress]) => !progress) ), - ([url]) => this.router.navigateByUrl(url) + ([url]) => router.navigateByUrl(url) ); } } diff --git a/src/app/core/models/product/__snapshots__/product.mapper.spec.ts.snap b/src/app/core/models/product/__snapshots__/product.mapper.spec.ts.snap index 5eefb6c4b7..157af02486 100644 --- a/src/app/core/models/product/__snapshots__/product.mapper.spec.ts.snap +++ b/src/app/core/models/product/__snapshots__/product.mapper.spec.ts.snap @@ -46,7 +46,7 @@ Object { }, "longDescription": undefined, "manufacturer": "Kodak", - "maxOrderQuantity": 100, + "maxOrderQuantity": undefined, "minOrderQuantity": 5, "name": "Kodak M series EasyShare M552", "packingUnit": "pcs.", @@ -61,7 +61,7 @@ Object { }, "shortDescription": "EasyShare M552, 14MP, 6.858 cm (2.7 \\") LCD, 4x, 28mm, HD 720p, Black", "sku": "7912057", - "stepOrderQuantity": 1, + "stepOrderQuantity": undefined, "type": "Product", } `; diff --git a/src/app/core/models/product/product.mapper.ts b/src/app/core/models/product/product.mapper.ts index cae5622a92..4108259ae9 100644 --- a/src/app/core/models/product/product.mapper.ts +++ b/src/app/core/models/product/product.mapper.ts @@ -47,10 +47,6 @@ export class ProductMapper { private categoryMapper: CategoryMapper ) {} - private defaultMinOrderQuantity = 1; - private defaultMaxOrderQuantity = 100; - private defaultStepOrderQuantity = 1; - static parseSkuFromURI(uri: string): string { const match = /products[^\/]*\/([^\?]*)/.exec(uri); if (match) { @@ -157,13 +153,9 @@ export class ProductMapper { retrieveStubAttributeValue(data, 'inStock') ), longDescription: undefined, - minOrderQuantity: - retrieveStubAttributeValue<{ value: number }>(data, 'minOrderQuantity')?.value || this.defaultMinOrderQuantity, - maxOrderQuantity: - retrieveStubAttributeValue<{ value: number }>(data, 'maxOrderQuantity')?.value || this.defaultMaxOrderQuantity, - stepOrderQuantity: - retrieveStubAttributeValue<{ value: number }>(data, 'stepOrderQuantity')?.value || - this.defaultStepOrderQuantity, + minOrderQuantity: retrieveStubAttributeValue<{ value: number }>(data, 'minOrderQuantity')?.value, + maxOrderQuantity: retrieveStubAttributeValue<{ value: number }>(data, 'maxOrderQuantity')?.value, + stepOrderQuantity: retrieveStubAttributeValue<{ value: number }>(data, 'stepOrderQuantity')?.value, packingUnit: retrieveStubAttributeValue(data, 'packingUnit'), attributeGroups: data.attributeGroup && mapAttributeGroups(data), readyForShipmentMin: undefined, @@ -187,9 +179,9 @@ export class ProductMapper { shortDescription: data.shortDescription, longDescription: data.longDescription, available: this.calculateAvailable(data.availability, data.inStock), - minOrderQuantity: data.minOrderQuantity || this.defaultMinOrderQuantity, - maxOrderQuantity: data.maxOrderQuantity || this.defaultMaxOrderQuantity, - stepOrderQuantity: data.stepOrderQuantity || this.defaultStepOrderQuantity, + minOrderQuantity: data.minOrderQuantity, + maxOrderQuantity: data.maxOrderQuantity, + stepOrderQuantity: data.stepOrderQuantity, packingUnit: data.packingUnit, availableStock: data.availableStock, attributes: data.attributeGroups?.PRODUCT_DETAIL_ATTRIBUTES?.attributes || data.attributes,