From 5b9847cf2bfa7b0bdfdca01dd3148b63eb768315 Mon Sep 17 00:00:00 2001 From: Danilo Hoffmann Date: Mon, 23 Nov 2020 13:13:19 +0100 Subject: [PATCH] feat: use new REST API for filters and products on master variation page (#414) * feat: use new REST API for filters and products on master variation page * fix: use correct display name for badge * refactor: restructure filter service * refactor: display product variation count depending on selected filters for product tile * chore: hide verbose re-occuring actions in redux dev tools * refactor: propagate filters from product listing to master variation page * fix: default change detection for price component for updates in FF --- src/app/core/facades/shopping.facade.ts | 9 +- .../product-variation.helper.spec.ts | 202 ++++++++++++------ .../product-variation.helper.ts | 28 ++- .../search-parameter.mapper.spec.ts | 14 -- .../search-parameter.mapper.ts | 28 --- .../search-parameter.model.ts | 5 - .../services/filter/filter.service.spec.ts | 74 ++++--- .../core/services/filter/filter.service.ts | 105 ++++----- .../product-master-variations.service.spec.ts | 176 --------------- .../product-master-variations.service.ts | 106 --------- .../services/products/products.service.ts | 32 +++ src/app/core/state-management.module.ts | 5 + .../store/shopping/filter/filter.actions.ts | 15 +- .../shopping/filter/filter.effects.spec.ts | 34 +-- .../store/shopping/filter/filter.effects.ts | 39 +++- .../product-listing.actions.ts | 5 - .../product-listing.effects.ts | 74 +------ .../product-listing.reducer.ts | 13 +- .../shopping/products/products.actions.ts | 10 + .../shopping/products/products.effects.ts | 34 +++ .../shopping/products/products.selectors.ts | 8 + src/app/core/utils/url-form-params.spec.ts | 52 ++++- src/app/core/utils/url-form-params.ts | 14 ++ .../filter-navigation-badges.component.html | 4 +- .../filter-navigation-badges.component.ts | 35 ++- .../product-price/product-price.component.ts | 2 +- .../product-row/product-row.component.html | 19 +- .../product-tile/product-tile.component.html | 16 +- .../product-tile.component.spec.ts | 3 + .../product-tile/product-tile.component.ts | 23 +- 30 files changed, 565 insertions(+), 619 deletions(-) delete mode 100644 src/app/core/models/search-parameter/search-parameter.mapper.spec.ts delete mode 100644 src/app/core/models/search-parameter/search-parameter.mapper.ts delete mode 100644 src/app/core/models/search-parameter/search-parameter.model.ts delete mode 100644 src/app/core/services/product-master-variations/product-master-variations.service.spec.ts delete mode 100644 src/app/core/services/product-master-variations/product-master-variations.service.ts diff --git a/src/app/core/facades/shopping.facade.ts b/src/app/core/facades/shopping.facade.ts index e829a28937..c98f9c0e7f 100644 --- a/src/app/core/facades/shopping.facade.ts +++ b/src/app/core/facades/shopping.facade.ts @@ -31,6 +31,7 @@ import { getProduct, getProductBundleParts, getProductLinks, + getProductVariationCount, getProductVariationOptions, getProducts, getSelectedProduct, @@ -98,6 +99,12 @@ export class ShoppingFacade { ); } + productVariationCount$(sku: string) { + return toObservable(sku).pipe( + switchMap(plainSKU => this.store.pipe(select(getProductVariationCount, { sku: plainSKU }))) + ); + } + productBundleParts$(sku: string) { return this.store.pipe(select(getProductBundleParts, { sku })); } @@ -166,7 +173,7 @@ export class ShoppingFacade { return this.store.pipe( select(getAvailableFilter), whenTruthy(), - map(x => (withCategoryFilter ? x : { ...x, filter: x.filter.filter(f => f.id !== 'CategoryUUIDLevelMulti') })) + map(x => (withCategoryFilter ? x : { ...x, filter: x.filter?.filter(f => f.id !== 'CategoryUUIDLevelMulti') })) ); } diff --git a/src/app/core/models/product-variation/product-variation.helper.spec.ts b/src/app/core/models/product-variation/product-variation.helper.spec.ts index 1dce1031b2..747da7072e 100644 --- a/src/app/core/models/product-variation/product-variation.helper.spec.ts +++ b/src/app/core/models/product-variation/product-variation.helper.spec.ts @@ -1,3 +1,4 @@ +import { FilterNavigation } from 'ish-core/models/filter-navigation/filter-navigation.model'; import { VariationProductView } from 'ish-core/models/product-view/product-view.model'; import { ProductVariationHelper } from './product-variation.helper'; @@ -18,6 +19,7 @@ const variationProduct = { { name: 'Attr 2', type: 'VariationAttribute', value: 'B', variationAttributeId: 'a2' }, { name: 'Attr 2', type: 'VariationAttribute', value: 'C', variationAttributeId: 'a2' }, ], + variations: () => variationProduct.variations(), }), variations: () => [ { @@ -60,78 +62,158 @@ const variationProduct = { } as VariationProductView; describe('Product Variation Helper', () => { - it('should build variation option groups for variation product', () => { - const expectedGroups = [ - { - id: 'a1', - label: 'Attr 1', - options: [ - { - label: 'A', - value: 'A', - type: 'a1', - alternativeCombination: false, - active: true, - }, + describe('buildVariationOptionGroups', () => { + it('should build variation option groups for variation product', () => { + const expectedGroups = [ + { + id: 'a1', + label: 'Attr 1', + options: [ + { + label: 'A', + value: 'A', + type: 'a1', + alternativeCombination: false, + active: true, + }, + { + label: 'B', + value: 'B', + type: 'a1', + alternativeCombination: false, + active: false, + }, + ], + }, + { + id: 'a2', + label: 'Attr 2', + options: [ + { + label: 'A', + value: 'A', + type: 'a2', + alternativeCombination: false, + active: true, + }, + { + label: 'B', + value: 'B', + type: 'a2', + alternativeCombination: false, + active: false, + }, + { + label: 'C', + value: 'C', + type: 'a2', + alternativeCombination: true, + active: false, + }, + ], + }, + ]; + + const result = ProductVariationHelper.buildVariationOptionGroups(variationProduct); + expect(result).toEqual(expectedGroups); + }); + }); + + describe('findPossibleVariationForSelection', () => { + it('should find possible variation on variation option group selection', () => { + // perfect match + expect( + ProductVariationHelper.findPossibleVariationForSelection({ a1: 'A', a2: 'B' }, variationProduct).sku + ).toEqual('333'); + + // possible varations + expect( + ProductVariationHelper.findPossibleVariationForSelection({ a1: 'A', a2: 'C' }, variationProduct).sku + ).toEqual('333'); + + expect( + ProductVariationHelper.findPossibleVariationForSelection({ a1: 'A', a2: 'C' }, variationProduct, 'a1').sku + ).toEqual('333'); + + expect( + ProductVariationHelper.findPossibleVariationForSelection({ a1: 'A', a2: 'C' }, variationProduct, 'a2').sku + ).toEqual('666'); + }); + }); + + describe('productVariationCount', () => { + it('should return zero when no inputs are given', () => { + expect(ProductVariationHelper.productVariationCount(undefined, undefined)).toEqual(0); + }); + + it('should use variation length when no filters are given', () => { + expect(ProductVariationHelper.productVariationCount(variationProduct.productMaster(), undefined)).toEqual(5); + }); + + it('should ignore irrelevant selections when counting', () => { + const filters = { + filter: [ { - label: 'B', - value: 'B', - type: 'a1', - alternativeCombination: false, - active: false, + id: 'xyz', + facets: [{ selected: true, name: 'xyz=foobar' }], }, ], - }, - { - id: 'a2', - label: 'Attr 2', - options: [ - { - label: 'A', - value: 'A', - type: 'a2', - alternativeCombination: false, - active: true, - }, + } as FilterNavigation; + + expect(ProductVariationHelper.productVariationCount(variationProduct.productMaster(), filters)).toEqual(5); + }); + + it('should filter for products matching single selected attributes', () => { + const filters = { + filter: [ { - label: 'B', - value: 'B', - type: 'a2', - alternativeCombination: false, - active: false, + id: 'a1', + facets: [ + { selected: true, name: 'a1=A' }, + { selected: false, name: 'a1=B' }, + ], }, { - label: 'C', - value: 'C', - type: 'a2', - alternativeCombination: true, - active: false, + id: 'xyz', + facets: [{ selected: true, name: 'xyz=foobar' }], }, ], - }, - ]; + } as FilterNavigation; - const result = ProductVariationHelper.buildVariationOptionGroups(variationProduct); - expect(result).toEqual(expectedGroups); - }); + expect(ProductVariationHelper.productVariationCount(variationProduct.productMaster(), filters)).toEqual(2); + }); - it('should find possible variation on variation option group selection', () => { - // perfect match - expect( - ProductVariationHelper.findPossibleVariationForSelection({ a1: 'A', a2: 'B' }, variationProduct).sku - ).toEqual('333'); + it('should filter for products matching multiple selected attributes', () => { + const filters = { + filter: [ + { + id: 'a2', + facets: [ + { selected: true, name: 'a2=A' }, + { selected: true, name: 'a2=C' }, + ], + }, + ], + } as FilterNavigation; - // possible varations - expect( - ProductVariationHelper.findPossibleVariationForSelection({ a1: 'A', a2: 'C' }, variationProduct).sku - ).toEqual('333'); + expect(ProductVariationHelper.productVariationCount(variationProduct.productMaster(), filters)).toEqual(3); + }); - expect( - ProductVariationHelper.findPossibleVariationForSelection({ a1: 'A', a2: 'C' }, variationProduct, 'a1').sku - ).toEqual('333'); + it('should filter for products matching multiple selected attributes over multiple facets', () => { + const filters = { + filter: [ + { + id: 'a1', + facets: [{ selected: true, name: 'a1=B' }], + }, + { + id: 'a2', + facets: [{ selected: true, name: 'a2=B' }], + }, + ], + } as FilterNavigation; - expect( - ProductVariationHelper.findPossibleVariationForSelection({ a1: 'A', a2: 'C' }, variationProduct, 'a2').sku - ).toEqual('666'); + expect(ProductVariationHelper.productVariationCount(variationProduct.productMaster(), filters)).toEqual(1); + }); }); }); diff --git a/src/app/core/models/product-variation/product-variation.helper.ts b/src/app/core/models/product-variation/product-variation.helper.ts index a717e341fe..fe341824f3 100644 --- a/src/app/core/models/product-variation/product-variation.helper.ts +++ b/src/app/core/models/product-variation/product-variation.helper.ts @@ -1,5 +1,6 @@ -import { groupBy } from 'lodash-es'; +import { flatten, groupBy } from 'lodash-es'; +import { FilterNavigation } from 'ish-core/models/filter-navigation/filter-navigation.model'; import { VariationProductMasterView, VariationProductView } from 'ish-core/models/product-view/product-view.model'; import { objectToArray } from 'ish-core/utils/functions'; @@ -153,4 +154,29 @@ export class ProductVariationHelper { static hasDefaultVariation(product: VariationProductMasterView): boolean { return product && !!product.defaultVariationSKU; } + + static productVariationCount(product: VariationProductMasterView, filters: FilterNavigation): number { + if (!product) { + return 0; + } else if (!filters?.filter) { + return product.variations()?.length; + } + + const selectedFacets = flatten( + filters.filter.map(filter => filter.facets.filter(facet => facet.selected).map(facet => facet.name)) + ).map(selected => selected.split('=')); + + return product + .variations() + .map(p => p.variableVariationAttributes) + .filter(attrs => + attrs.every( + attr => + // attribute is not selected + !selectedFacets.find(([key]) => key === attr.variationAttributeId) || + // selection is variation + selectedFacets.find(([key, val]) => key === attr.variationAttributeId && val === attr.value) + ) + ).length; + } } diff --git a/src/app/core/models/search-parameter/search-parameter.mapper.spec.ts b/src/app/core/models/search-parameter/search-parameter.mapper.spec.ts deleted file mode 100644 index 0c08858d44..0000000000 --- a/src/app/core/models/search-parameter/search-parameter.mapper.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SearchParameterMapper } from './search-parameter.mapper'; -import { SearchParameter } from './search-parameter.model'; - -describe('Search Parameter Mapper', () => { - describe('toData', () => { - it(`should return parameter string with encoded query term and sortings when SearchParameter query is applied`, () => { - const searchParameter = new SearchParameter(); - searchParameter.queryTerm = 'camera'; - searchParameter.sortings = ['name-asc', 'sku-desc']; - - expect(SearchParameterMapper.toData(searchParameter)).toEqual('&searchTerm=camera&@Sort.name=0&@Sort.sku=1'); - }); - }); -}); diff --git a/src/app/core/models/search-parameter/search-parameter.mapper.ts b/src/app/core/models/search-parameter/search-parameter.mapper.ts deleted file mode 100644 index 41fa81d53b..0000000000 --- a/src/app/core/models/search-parameter/search-parameter.mapper.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { SearchParameter } from './search-parameter.model'; - -export class SearchParameterMapper { - /** - * Converts the searchParameter to a base64 encoded string - */ - static toData(searchParameter: SearchParameter): string { - let data = ''; - - if (searchParameter.queryTerm) { - data += '&searchTerm=' + searchParameter.queryTerm; - } - if (searchParameter.data) { - data += searchParameter.data; - } - if (searchParameter.sortings) { - searchParameter.sortings.forEach(sorting => { - if (sorting.endsWith('-asc')) { - data += `&@Sort.${sorting.substr(0, sorting.length - '-asc'.length)}=0`; - } - if (sorting.endsWith('-desc')) { - data += `&@Sort.${sorting.substr(0, sorting.length - '-desc'.length)}=1`; - } - }); - } - return data; - } -} diff --git a/src/app/core/models/search-parameter/search-parameter.model.ts b/src/app/core/models/search-parameter/search-parameter.model.ts deleted file mode 100644 index 3e2b26ecf0..0000000000 --- a/src/app/core/models/search-parameter/search-parameter.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class SearchParameter { - queryTerm: string; - sortings?: string[]; - data?: string; -} diff --git a/src/app/core/services/filter/filter.service.spec.ts b/src/app/core/services/filter/filter.service.spec.ts index 8a79a309d4..b123530d39 100644 --- a/src/app/core/services/filter/filter.service.spec.ts +++ b/src/app/core/services/filter/filter.service.spec.ts @@ -1,11 +1,13 @@ import { TestBed } from '@angular/core/testing'; -import { provideMockStore } from '@ngrx/store/testing'; +import { Store } from '@ngrx/store'; import { of } from 'rxjs'; -import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { anything, capture, instance, mock, verify, when } from 'ts-mockito'; import { FilterNavigationData } from 'ish-core/models/filter-navigation/filter-navigation.interface'; -import { ApiService } from 'ish-core/services/api/api.service'; -import { getProductListingItemsPerPage } from 'ish-core/store/shopping/product-listing'; +import { ApiService, AvailableOptions } from 'ish-core/services/api/api.service'; +import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; +import { setProductListingPageSize } from 'ish-core/store/shopping/product-listing'; +import { ShoppingStoreModule } from 'ish-core/store/shopping/shopping-store.module'; import { URLFormParams } from 'ish-core/utils/url-form-params'; import { FilterService } from './filter.service'; @@ -14,7 +16,10 @@ describe('Filter Service', () => { let apiService: ApiService; let filterService: FilterService; const productsMock = { - elements: [{ uri: 'products/123' }, { uri: 'products/234' }], + elements: [ + { uri: 'products/123', attributes: [{ name: 'sku', value: '123' }] }, + { uri: 'products/234', attributes: [{ name: 'sku', value: '234' }] }, + ], total: 2, }; const filterMock = { @@ -36,18 +41,10 @@ describe('Filter Service', () => { apiService = mock(ApiService); TestBed.configureTestingModule({ - providers: [ - { provide: ApiService, useFactory: () => instance(apiService) }, - provideMockStore({ - selectors: [ - { - selector: getProductListingItemsPerPage, - value: { itemsPerPage: 2 }, - }, - ], - }), - ], + imports: [CoreStoreModule.forTesting(['configuration']), ShoppingStoreModule.forTesting('productListing')], + providers: [{ provide: ApiService, useFactory: () => instance(apiService) }], }); + TestBed.inject(Store).dispatch(setProductListingPageSize({ itemsPerPage: 2 })); filterService = TestBed.inject(FilterService); }); @@ -56,40 +53,61 @@ describe('Filter Service', () => { }); it("should get Filter data when 'getFilterForCategory' is called", done => { - when(apiService.get('categories/A/B/productfilters', anything())).thenReturn(of(filterMock)); + when(apiService.get(anything())).thenReturn(of(filterMock)); filterService.getFilterForCategory('A.B').subscribe(data => { expect(data.filter).toHaveLength(1); expect(data.filter[0].facets).toHaveLength(2); expect(data.filter[0].facets[0].name).toEqual('a'); expect(data.filter[0].facets[1].name).toEqual('b'); - verify(apiService.get('categories/A/B/productfilters', anything())).once(); + + verify(apiService.get(anything())).once(); + expect(capture(apiService.get).last()).toMatchInlineSnapshot(` + Array [ + "categories/A/B/productfilters", + ] + `); + done(); }); }); it("should get Filter data when 'applyFilter' is called", done => { - when(apiService.get('productfilters?SearchParameter=b', anything())).thenReturn( - of(filterMock) - ); + when(apiService.get(anything(), anything())).thenReturn(of(filterMock)); filterService.applyFilter({ SearchParameter: ['b'] } as URLFormParams).subscribe(data => { expect(data.filter).toHaveLength(1); expect(data.filter[0].facets).toHaveLength(2); expect(data.filter[0].facets[0].name).toEqual('a'); expect(data.filter[0].facets[1].name).toEqual('b'); - verify(apiService.get('productfilters?SearchParameter=b', anything())).once(); + + verify(apiService.get(anything(), anything())).once(); + const [resource, params] = capture(apiService.get).last(); + expect(resource).toMatchInlineSnapshot(`"productfilters"`); + expect((params as AvailableOptions)?.params?.toString()).toMatchInlineSnapshot(`"SearchParameter=b"`); + done(); }); }); it("should get Product SKUs when 'getFilteredProducts' is called", done => { - when(apiService.get('products?SearchParameter=b&returnSortKeys=true', anything())).thenReturn(of(productsMock)); + when(apiService.get(anything(), anything())).thenReturn(of(productsMock)); filterService.getFilteredProducts({ SearchParameter: ['b'] } as URLFormParams, 1).subscribe(data => { - expect(data).toEqual({ - productSKUs: ['123', '234'], - total: 2, - }); - verify(apiService.get('products?SearchParameter=b&returnSortKeys=true', anything())).once(); + expect(data?.products?.map(p => p.sku)).toMatchInlineSnapshot(` + Array [ + "123", + "234", + ] + `); + expect(data?.total).toMatchInlineSnapshot(`2`); + expect(data?.sortKeys).toMatchInlineSnapshot(`undefined`); + + verify(apiService.get(anything(), anything())).once(); + const [resource, params] = capture(apiService.get).last(); + expect(resource).toMatchInlineSnapshot(`"products"`); + expect((params as AvailableOptions)?.params?.toString()).toMatchInlineSnapshot( + `"amount=2&offset=0&attrs=sku,salePrice,listPrice,availability,manufacturer,image,minOrderQuantity,inStock,promotions,packingUnit,mastered,productMaster,productMasterSKU,roundedAverageRating,retailSet&attributeGroup=PRODUCT_LABEL_ATTRIBUTES&returnSortKeys=true&SearchParameter=b"` + ); + done(); }); }); diff --git a/src/app/core/services/filter/filter.service.ts b/src/app/core/services/filter/filter.service.ts index 4bcf0bdf97..dc0361c3f8 100644 --- a/src/app/core/services/filter/filter.service.ts +++ b/src/app/core/services/filter/filter.service.ts @@ -1,20 +1,23 @@ import { HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store, select } from '@ngrx/store'; -import { Observable } from 'rxjs'; +import { omit } from 'lodash-es'; +import { Observable, identity } from 'rxjs'; import { map } from 'rxjs/operators'; import { AttributeGroupTypes } from 'ish-core/models/attribute-group/attribute-group.types'; +import { CategoryHelper } from 'ish-core/models/category/category.model'; import { FilterNavigationData } from 'ish-core/models/filter-navigation/filter-navigation.interface'; import { FilterNavigationMapper } from 'ish-core/models/filter-navigation/filter-navigation.mapper'; import { FilterNavigation } from 'ish-core/models/filter-navigation/filter-navigation.model'; -import { Link } from 'ish-core/models/link/link.model'; +import { ProductDataStub } from 'ish-core/models/product/product.interface'; import { ProductMapper } from 'ish-core/models/product/product.mapper'; -import { SearchParameterMapper } from 'ish-core/models/search-parameter/search-parameter.mapper'; +import { Product, ProductHelper } from 'ish-core/models/product/product.model'; import { ApiService } from 'ish-core/services/api/api.service'; import { ProductsService } from 'ish-core/services/products/products.service'; import { getProductListingItemsPerPage } from 'ish-core/store/shopping/product-listing'; -import { URLFormParams, formParamsToString } from 'ish-core/utils/url-form-params'; +import { FeatureToggleService } from 'ish-core/utils/feature-toggle/feature-toggle.service'; +import { URLFormParams, appendFormParamsToHttpParams } from 'ish-core/utils/url-form-params'; @Injectable({ providedIn: 'root' }) export class FilterService { @@ -23,7 +26,9 @@ export class FilterService { constructor( private apiService: ApiService, private filterNavigationMapper: FilterNavigationMapper, - private store: Store + private productMapper: ProductMapper, + private store: Store, + private featureToggleService: FeatureToggleService ) { this.store .pipe(select(getProductListingItemsPerPage)) @@ -31,33 +36,43 @@ export class FilterService { } getFilterForCategory(categoryUniqueId: string): Observable { - const categoryPath = categoryUniqueId.split('.').join('/'); - return this.applyFilterWithCategory('', categoryPath).pipe( - map(filter => this.filterNavigationMapper.fromData(filter)) - ); + const category = CategoryHelper.getCategoryPath(categoryUniqueId); + return this.apiService + .get(`categories/${category}/productfilters`) + .pipe(map(filter => this.filterNavigationMapper.fromData(filter))); } getFilterForSearch(searchTerm: string): Observable { - const searchParameter = SearchParameterMapper.toData({ queryTerm: searchTerm }); return this.apiService - .get(`productfilters?${searchParameter}`, { skipApiErrorHandling: true }) + .get(`productfilters`, { params: new HttpParams().set('searchTerm', searchTerm) }) + .pipe(map(filter => this.filterNavigationMapper.fromData(filter))); + } + + getFilterForMaster(masterSKU: string): Observable { + return this.apiService + .get(`productfilters`, { + params: new HttpParams().set('MasterSKU', masterSKU), + }) .pipe(map(filter => this.filterNavigationMapper.fromData(filter))); } applyFilter(searchParameter: URLFormParams): Observable { - const params = formParamsToString({ ...searchParameter, category: undefined }); - const categoryPath = searchParameter.category ? searchParameter.category[0] : undefined; - return (categoryPath - ? this.applyFilterWithCategory(params, categoryPath) - : this.applyFilterWithoutCategory(params) - ).pipe(map(filter => this.filterNavigationMapper.fromData(filter))); + const params = appendFormParamsToHttpParams(omit(searchParameter, 'category')); + + const resource = searchParameter.category + ? `categories/${searchParameter.category[0]}/productfilters` + : 'productfilters'; + + return this.apiService + .get(resource, { params }) + .pipe(map(filter => this.filterNavigationMapper.fromData(filter))); } getFilteredProducts( searchParameter: URLFormParams, page: number = 1, sortKey?: string - ): Observable<{ total: number; productSKUs: string[]; sortKeys: string[] }> { + ): Observable<{ total: number; products: Partial[]; sortKeys: string[] }> { let params = new HttpParams() .set('amount', this.itemsPerPage.toString()) .set('offset', ((page - 1) * this.itemsPerPage).toString()) @@ -67,46 +82,32 @@ export class FilterService { if (sortKey) { params = params.set('sortKey', sortKey); } + params = appendFormParamsToHttpParams(omit(searchParameter, 'category'), params); - const searchParameterString = formParamsToString({ ...searchParameter, category: undefined }); - const categoryPath = searchParameter.category ? searchParameter.category[0] : undefined; + const resource = searchParameter.category ? `categories/${searchParameter.category[0]}/products` : 'products'; - return (categoryPath - ? this.getFilteredProductsWithCategory(searchParameterString, categoryPath, params) - : this.getFilteredProductsWithoutCategory(searchParameterString, params) - ).pipe( - map((x: { total: number; elements: Link[]; sortKeys: string[] }) => ({ - productSKUs: x.elements.map(l => l.uri).map(ProductMapper.parseSKUfromURI), + return this.apiService.get(resource, { params }).pipe( + map((x: { total: number; elements: ProductDataStub[]; sortKeys: string[] }) => ({ + products: x.elements.map(stub => this.productMapper.fromStubData(stub)), total: x.total, sortKeys: x.sortKeys, - })) + })), + params.has('MasterSKU') + ? identity + : map(({ products, sortKeys, total }) => ({ products: this.postProcessMasters(products), sortKeys, total })) ); } - private getFilteredProductsWithoutCategory(searchParameter: string, params: HttpParams) { - return this.apiService.get(`products${(searchParameter ? `?${searchParameter}&` : '?') + 'returnSortKeys=true'}`, { - params, - }); - } - - private getFilteredProductsWithCategory(searchParameter: string, category: string, params: HttpParams) { - return this.apiService.get( - `categories/${category}/products${(searchParameter ? `?${searchParameter}&` : '?') + 'returnSortKeys=true'}`, - { params } - ); - } - - private applyFilterWithoutCategory(searchParameter: string): Observable { - const params = searchParameter ? `?${searchParameter}` : ''; - return this.apiService.get(`productfilters${params}`, { - skipApiErrorHandling: true, - }); - } - - private applyFilterWithCategory(searchParameter: string, category: string): Observable { - const params = searchParameter ? `?${searchParameter}` : ''; - return this.apiService.get(`categories/${category}/productfilters${params}`, { - skipApiErrorHandling: true, - }); + /** + * exchange single-return variation products to master products for B2B + * TODO: this is a work-around + */ + private postProcessMasters(products: Partial[]): Product[] { + if (this.featureToggleService.enabled('advancedVariationHandling')) { + return products.map(p => + ProductHelper.isVariationProduct(p) ? { sku: p.productMasterSKU, completenessLevel: 0 } : p + ) as Product[]; + } + return products as Product[]; } } diff --git a/src/app/core/services/product-master-variations/product-master-variations.service.spec.ts b/src/app/core/services/product-master-variations/product-master-variations.service.spec.ts deleted file mode 100644 index 3067a3e948..0000000000 --- a/src/app/core/services/product-master-variations/product-master-variations.service.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { Filter } from 'ish-core/models/filter/filter.model'; -import { VariationProductMasterView, VariationProductView } from 'ish-core/models/product-view/product-view.model'; -import { URLFormParams, formParamsToString } from 'ish-core/utils/url-form-params'; - -import { ProductMasterVariationsService } from './product-master-variations.service'; - -describe('Product Master Variations Service', () => { - let productMasterVariationsService: ProductMasterVariationsService; - let master: VariationProductMasterView; - - const hdd = (value: string) => ({ - name: 'Hard drive size', - type: 'VariationAttribute', - variationAttributeId: 'HDD', - value, - }); - const color = (value: string) => ({ - name: 'Color', - type: 'VariationAttribute', - variationAttributeId: 'COL', - value, - }); - - beforeEach(() => { - TestBed.configureTestingModule({}); - productMasterVariationsService = TestBed.inject(ProductMasterVariationsService); - - expect.addSnapshotSerializer({ - test: val => val && val.facets && val.facets.length, - print: (val: Filter, serialize) => - `${val.id}: ` + - serialize( - val.facets.map( - facet => `${facet.name}:${facet.selected}:${facet.count} -> ${formParamsToString(facet.searchParameter)}` - ) - ), - }); - - master = { - variationAttributeValues: [hdd('512GB'), hdd('256GB'), color('Brown'), color('Red'), color('Black')], - variations: () => - [ - { sku: 'V1', variableVariationAttributes: [hdd('512GB'), color('Brown')] }, - { sku: 'V2', variableVariationAttributes: [hdd('256GB'), color('Brown')] }, - { sku: 'V3', variableVariationAttributes: [hdd('512GB'), color('Red')] }, - { sku: 'V4', variableVariationAttributes: [hdd('256GB'), color('Red')] }, - { sku: 'V5', variableVariationAttributes: [hdd('512GB'), color('Black')] }, - ] as VariationProductView[], - } as VariationProductMasterView; - }); - - it('should be created', () => { - expect(productMasterVariationsService).toBeTruthy(); - }); - - describe('without extra filters activated', () => { - it('should respond with unselected filters and all variations when queried', () => { - expect(productMasterVariationsService.getFiltersAndFilteredVariationsForMasterProduct(master, {})) - .toMatchInlineSnapshot(` - Object { - "filterNavigation": Object { - "filter": Array [ - HDD: Array [ - "512GB:false:3 -> HDD=512GB", - "256GB:false:2 -> HDD=256GB", - ], - COL: Array [ - "Brown:false:2 -> COL=Brown", - "Red:false:2 -> COL=Red", - "Black:false:1 -> COL=Black", - ], - ], - }, - "products": Array [ - "V1", - "V2", - "V3", - "V4", - "V5", - ], - } - `); - }); - }); - - describe('with extra single filters activated', () => { - it('should respond with selected filters and all variations when queried', () => { - expect( - productMasterVariationsService.getFiltersAndFilteredVariationsForMasterProduct(master, { - HDD: ['512GB'], - COL: ['Red'], - } as URLFormParams) - ).toMatchInlineSnapshot(` - Object { - "filterNavigation": Object { - "filter": Array [ - HDD: Array [ - "512GB:true:3 -> COL=Red", - "256GB:false:2 -> HDD=512GB,256GB&COL=Red", - ], - COL: Array [ - "Brown:false:2 -> HDD=512GB&COL=Red,Brown", - "Red:true:2 -> HDD=512GB", - "Black:false:1 -> HDD=512GB&COL=Red,Black", - ], - ], - }, - "products": Array [ - "V3", - ], - } - `); - }); - }); - - describe('with extra single filters restricting other filters activated', () => { - it('should respond with selected filters and all variations when queried', () => { - expect( - productMasterVariationsService.getFiltersAndFilteredVariationsForMasterProduct(master, { - COL: ['Black'], - } as URLFormParams) - ).toMatchInlineSnapshot(` - Object { - "filterNavigation": Object { - "filter": Array [ - HDD: Array [ - "512GB:false:3 -> COL=Black&HDD=512GB", - ], - COL: Array [ - "Brown:false:2 -> COL=Black,Brown", - "Red:false:2 -> COL=Black,Red", - "Black:true:1 -> ", - ], - ], - }, - "products": Array [ - "V5", - ], - } - `); - }); - }); - - describe('with extra multi filters activated', () => { - it('should respond with selected filters and all variations when queried', () => { - expect( - productMasterVariationsService.getFiltersAndFilteredVariationsForMasterProduct(master, { - HDD: ['512GB', '256GB'], - COL: ['Red'], - } as URLFormParams) - ).toMatchInlineSnapshot(` - Object { - "filterNavigation": Object { - "filter": Array [ - HDD: Array [ - "512GB:true:3 -> HDD=256GB&COL=Red", - "256GB:true:2 -> HDD=512GB&COL=Red", - ], - COL: Array [ - "Brown:false:2 -> HDD=512GB,256GB&COL=Red,Brown", - "Red:true:2 -> HDD=512GB,256GB", - "Black:false:1 -> HDD=512GB,256GB&COL=Red,Black", - ], - ], - }, - "products": Array [ - "V3", - "V4", - ], - } - `); - }); - }); -}); diff --git a/src/app/core/services/product-master-variations/product-master-variations.service.ts b/src/app/core/services/product-master-variations/product-master-variations.service.ts deleted file mode 100644 index 8ff678fbf8..0000000000 --- a/src/app/core/services/product-master-variations/product-master-variations.service.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Injectable } from '@angular/core'; -import { groupBy } from 'lodash-es'; - -import { Facet } from 'ish-core/models/facet/facet.model'; -import { FilterNavigation } from 'ish-core/models/filter-navigation/filter-navigation.model'; -import { VariationAttribute } from 'ish-core/models/product-variation/variation-attribute.model'; -import { VariationProductMasterView, VariationProductView } from 'ish-core/models/product-view/product-view.model'; -import { URLFormParams } from 'ish-core/utils/url-form-params'; - -@Injectable({ providedIn: 'root' }) -export class ProductMasterVariationsService { - getFiltersAndFilteredVariationsForMasterProduct( - product: VariationProductMasterView, - filters: URLFormParams - ): { filterNavigation: FilterNavigation; products: string[] } { - return { - filterNavigation: this.createFilterNavigation(product, filters), - products: this.filterVariations(product, filters), - }; - } - - private filterVariations(product: VariationProductMasterView, filters: URLFormParams): string[] { - if (filters && Object.keys(filters).length) { - return this.potentialMatches(filters, product.variations()).map(p => p.sku); - } else { - return product.variations().map(p => p.sku); - } - } - - private removed(array: string[], value: string): string[] { - if (!array || !array.length) { - return []; - } - - const ret = [...array]; - ret.splice(array.indexOf(value), 1); - return ret; - } - - private added(array: string[], value: string): string[] { - if (!array || !array.length) { - return [value]; - } - return [...array, value]; - } - - private potentialMatches(newFilters: URLFormParams, variations: VariationProductView[]) { - return variations.filter(variation => - Object.keys(newFilters) - .filter(facet => newFilters[facet].length) - .every(facet => - newFilters[facet].some( - val => - !!variation.variableVariationAttributes.find( - attr => attr.variationAttributeId === facet && attr.value === val - ) - ) - ) - ); - } - - private createFacet( - filterName: string, - attribute: VariationAttribute, - filtersURLFormParams: URLFormParams, - variations: VariationProductView[] - ): Facet { - const filters = filtersURLFormParams || {}; - const selected = !!filters[filterName] && filters[filterName].includes(attribute.value); - const newFilters = { - ...filters, - [filterName]: selected - ? this.removed(filters[filterName], attribute.value) - : this.added(filters[filterName], attribute.value), - }; - return { - name: attribute.value, - searchParameter: newFilters, - count: - this.potentialMatches(newFilters, variations).length && - variations.filter(variation => - variation.variableVariationAttributes.find( - att => att.variationAttributeId === attribute.variationAttributeId && att.value === attribute.value - ) - ).length, - displayName: attribute.value, - selected, - level: 0, - }; - } - - private createFilterNavigation(product: VariationProductMasterView, filters: URLFormParams): FilterNavigation { - const groups = groupBy(product.variationAttributeValues, val => val.variationAttributeId); - return { - filter: Object.keys(groups).map(key => ({ - id: key, - displayType: 'checkbox', - name: groups[key][0].name, - selectionType: 'multiple_or', - facets: groups[key] - .map(val => this.createFacet(key, val, filters, product.variations())) - .filter(facet => !!facet.count || facet.selected), - })), - }; - } -} diff --git a/src/app/core/services/products/products.service.ts b/src/app/core/services/products/products.service.ts index 45fb0768d7..b2de47c3bf 100644 --- a/src/app/core/services/products/products.service.ts +++ b/src/app/core/services/products/products.service.ts @@ -133,6 +133,38 @@ export class ProductsService { ); } + getProductsForMaster( + masterSKU: string, + page: number = 1, + sortKey?: string + ): Observable<{ products: Product[]; sortKeys: string[]; total: number }> { + if (!masterSKU) { + return throwError('getProductsForMaster() called without masterSKU'); + } + + let params = new HttpParams() + .set('MasterSKU', masterSKU) + .set('amount', this.itemsPerPage.toString()) + .set('offset', ((page - 1) * this.itemsPerPage).toString()) + .set('attrs', ProductsService.STUB_ATTRS) + .set('attributeGroup', AttributeGroupTypes.ProductLabelAttributes) + .set('returnSortKeys', 'true'); + if (sortKey) { + params = params.set('sortKey', sortKey); + } + + return this.apiService + .get<{ elements: ProductDataStub[]; sortKeys: string[]; total: number }>('products', { params }) + .pipe( + map(response => ({ + products: response.elements.map(element => this.productMapper.fromStubData(element)) as Product[], + sortKeys: response.sortKeys, + total: response.total ? response.total : response.elements.length, + })), + map(({ products, sortKeys, total }) => ({ products, sortKeys, total })) + ); + } + /** * exchange single-return variation products to master products for B2B * TODO: this is a work-around diff --git a/src/app/core/state-management.module.ts b/src/app/core/state-management.module.ts index 24a4e178b3..15fb350aa4 100644 --- a/src/app/core/state-management.module.ts +++ b/src/app/core/state-management.module.ts @@ -9,9 +9,13 @@ import { environment } from '../../environments/environment'; import { ngrxStateTransfer } from './configurations/ngrx-state-transfer'; import { ContentStoreModule } from './store/content/content-store.module'; import { CoreStoreModule } from './store/core/core-store.module'; +import { setStickyHeader } from './store/core/viewconf'; import { CustomerStoreModule } from './store/customer/customer-store.module'; import { GeneralStoreModule } from './store/general/general-store.module'; import { HybridStoreModule } from './store/hybrid/hybrid-store.module'; +import { loadProductIfNotLoaded } from './store/shopping/products'; +import { loadPromotion } from './store/shopping/promotions'; +import { suggestSearch } from './store/shopping/search'; import { ShoppingStoreModule } from './store/shopping/shopping-store.module'; @NgModule({ @@ -21,6 +25,7 @@ import { ShoppingStoreModule } from './store/shopping/shopping-store.module'; StoreDevtoolsModule.instrument({ maxAge: environment.production ? 25 : 200, logOnly: environment.production, // Restrict extension to log-only mode + actionsBlocklist: [loadPromotion.type, loadProductIfNotLoaded.type, setStickyHeader.type, suggestSearch.type], }), GeneralStoreModule, CustomerStoreModule, diff --git a/src/app/core/store/shopping/filter/filter.actions.ts b/src/app/core/store/shopping/filter/filter.actions.ts index f6b85fc38c..6e6c0f74ac 100644 --- a/src/app/core/store/shopping/filter/filter.actions.ts +++ b/src/app/core/store/shopping/filter/filter.actions.ts @@ -10,6 +10,16 @@ export const loadFilterForCategory = createAction( payload<{ uniqueId: string }>() ); +export const loadFilterForSearch = createAction( + '[Filter Internal] Load Filter for Search', + payload<{ searchTerm: string }>() +); + +export const loadFilterForMaster = createAction( + '[Filter Internal] Load Filter for Master', + payload<{ masterSKU: string }>() +); + export const loadFilterSuccess = createAction( '[Filter API] Load Filter Success', payload<{ filterNavigation: FilterNavigation }>() @@ -17,11 +27,6 @@ export const loadFilterSuccess = createAction( export const loadFilterFail = createAction('[Filter API] Load Filter Fail', httpError()); -export const loadFilterForSearch = createAction( - '[Filter Internal] Load Filter for Search', - payload<{ searchTerm: string }>() -); - export const applyFilter = createAction('[Filter] Apply Filter', payload<{ searchParameter: URLFormParams }>()); export const applyFilterSuccess = createAction( diff --git a/src/app/core/store/shopping/filter/filter.effects.spec.ts b/src/app/core/store/shopping/filter/filter.effects.spec.ts index bf1876533c..d48eda77a7 100644 --- a/src/app/core/store/shopping/filter/filter.effects.spec.ts +++ b/src/app/core/store/shopping/filter/filter.effects.spec.ts @@ -3,12 +3,12 @@ import { provideMockActions } from '@ngrx/effects/testing'; import { Action } from '@ngrx/store'; import { cold, hot } from 'jest-marbles'; import { Observable, of, throwError } from 'rxjs'; +import { toArray } from 'rxjs/operators'; import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; import { PRODUCT_LISTING_ITEMS_PER_PAGE } from 'ish-core/configurations/injection-keys'; import { FilterNavigation } from 'ish-core/models/filter-navigation/filter-navigation.model'; import { FilterService } from 'ish-core/services/filter/filter.service'; -import { setProductListingPages } from 'ish-core/store/shopping/product-listing'; import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; import { @@ -54,7 +54,7 @@ describe('Filter Effects', () => { } else { return of({ total: 2, - productSKUs: ['123', '234'], + products: [{ sku: '123' }, { sku: '234' }], }); } }); @@ -142,29 +142,31 @@ describe('Filter Effects', () => { }); describe('loadFilteredProducts$', () => { - it('should trigger product actions for ApplyFilterSuccess action', () => { + it('should trigger product actions for ApplyFilterSuccess action', done => { const action = loadProductsForFilter({ id: { type: 'search', value: 'test', filters: { searchTerm: ['b*'] }, }, - searchParameter: { param: ['b'] }, }); - const completion = setProductListingPages({ - id: { - type: 'search', - value: 'test', - filters: { searchTerm: ['b*'] }, - }, - 1: ['123', '234'], - itemCount: 2, - sortKeys: [], + + actions$ = of(action); + effects.loadFilteredProducts$.pipe(toArray()).subscribe(actions => { + expect(actions).toMatchInlineSnapshot(` + [Products API] Load Product Success: + product: {"sku":"123"} + [Products API] Load Product Success: + product: {"sku":"234"} + [Product Listing Internal] Set Product Listing Pages: + 1: ["123","234"] + id: {"type":"search","value":"test","filters":{"searchTerm":[1]}} + itemCount: 2 + sortKeys: [] + `); + done(); }); - actions$ = hot(' ---b-|', { b: action }); - const expected$ = cold('---c-|', { c: completion }); - expect(effects.loadFilteredProducts$).toBeObservable(expected$); }); }); diff --git a/src/app/core/store/shopping/filter/filter.effects.ts b/src/app/core/store/shopping/filter/filter.effects.ts index fa34183b0f..a889d32d79 100644 --- a/src/app/core/store/shopping/filter/filter.effects.ts +++ b/src/app/core/store/shopping/filter/filter.effects.ts @@ -3,9 +3,10 @@ import { Actions, createEffect, ofType } from '@ngrx/effects'; import { map, mergeMap, switchMap } from 'rxjs/operators'; import { ProductListingMapper } from 'ish-core/models/product-listing/product-listing.mapper'; +import { Product } from 'ish-core/models/product/product.model'; import { FilterService } from 'ish-core/services/filter/filter.service'; import { setProductListingPages } from 'ish-core/store/shopping/product-listing'; -import { loadProductFail } from 'ish-core/store/shopping/products'; +import { loadProductFail, loadProductSuccess } from 'ish-core/store/shopping/products'; import { mapErrorToAction, mapToPayload, mapToPayloadProperty } from 'ish-core/utils/operators'; import { @@ -14,6 +15,7 @@ import { applyFilterSuccess, loadFilterFail, loadFilterForCategory, + loadFilterForMaster, loadFilterForSearch, loadFilterSuccess, loadProductsForFilter, @@ -53,6 +55,19 @@ export class FilterEffects { ) ); + loadFilterForMaster$ = createEffect(() => + this.actions$.pipe( + ofType(loadFilterForMaster), + mapToPayloadProperty('masterSKU'), + mergeMap(masterSKU => + this.filterService.getFilterForMaster(masterSKU).pipe( + map(filterNavigation => loadFilterSuccess({ filterNavigation })), + mapErrorToAction(loadFilterFail) + ) + ) + ) + ); + applyFilter$ = createEffect(() => this.actions$.pipe( ofType(applyFilter), @@ -72,15 +87,21 @@ export class FilterEffects { mapToPayload(), switchMap(({ id, searchParameter, page, sorting }) => this.filterService.getFilteredProducts(searchParameter, page, sorting).pipe( - mergeMap(({ productSKUs, total, sortKeys }) => [ + mergeMap(({ products, total, sortKeys }) => [ + ...products.map((product: Product) => loadProductSuccess({ product })), setProductListingPages( - this.productListingMapper.createPages(productSKUs, id.type, id.value, { - filters: id.filters, - itemCount: total, - startPage: page, - sortKeys, - sorting, - }) + this.productListingMapper.createPages( + products.map(p => p.sku), + id.type, + id.value, + { + filters: id.filters, + itemCount: total, + startPage: page, + sortKeys, + sorting, + } + ) ), ]), mapErrorToAction(loadProductFail) diff --git a/src/app/core/store/shopping/product-listing/product-listing.actions.ts b/src/app/core/store/shopping/product-listing/product-listing.actions.ts index 00acf02f3c..185d2e3a77 100644 --- a/src/app/core/store/shopping/product-listing/product-listing.actions.ts +++ b/src/app/core/store/shopping/product-listing/product-listing.actions.ts @@ -26,8 +26,3 @@ export const loadMoreProductsForParams = createAction( ); export const setViewType = createAction('[Product Listing Internal] Set View Type', payload<{ viewType: ViewType }>()); - -export const loadPagesForMaster = createAction( - '[Product Listing Internal] Load Pages For Master', - payload<{ id: ProductListingID; filters: URLFormParams; sorting: string }>() -); diff --git a/src/app/core/store/shopping/product-listing/product-listing.effects.ts b/src/app/core/store/shopping/product-listing/product-listing.effects.ts index 2eefb453f2..328644fcda 100644 --- a/src/app/core/store/shopping/product-listing/product-listing.effects.ts +++ b/src/app/core/store/shopping/product-listing/product-listing.effects.ts @@ -2,26 +2,23 @@ import { Inject, Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store, select } from '@ngrx/store'; import { isEqual } from 'lodash-es'; -import { distinctUntilChanged, filter, map, mapTo, mergeMap, switchMap, take } from 'rxjs/operators'; +import { distinctUntilChanged, map, mapTo, switchMap, take } from 'rxjs/operators'; import { DEFAULT_PRODUCT_LISTING_VIEW_TYPE, PRODUCT_LISTING_ITEMS_PER_PAGE, } from 'ish-core/configurations/injection-keys'; -import { ProductListingMapper } from 'ish-core/models/product-listing/product-listing.mapper'; import { ProductListingView } from 'ish-core/models/product-listing/product-listing.model'; -import { Product, ProductCompletenessLevel, ProductHelper } from 'ish-core/models/product/product.model'; import { ViewType } from 'ish-core/models/viewtype/viewtype.types'; -import { ProductMasterVariationsService } from 'ish-core/services/product-master-variations/product-master-variations.service'; import { selectQueryParam, selectQueryParams } from 'ish-core/store/core/router'; import { applyFilter, loadFilterForCategory, + loadFilterForMaster, loadFilterForSearch, - loadFilterSuccess, loadProductsForFilter, } from 'ish-core/store/shopping/filter'; -import { getProduct, loadProductsForCategory } from 'ish-core/store/shopping/products'; +import { loadProductsForCategory, loadProductsForMaster } from 'ish-core/store/shopping/products'; import { searchProducts } from 'ish-core/store/shopping/search'; import { mapToPayload, whenFalsy, whenTruthy } from 'ish-core/utils/operators'; import { stringToFormParams } from 'ish-core/utils/url-form-params'; @@ -29,7 +26,6 @@ import { stringToFormParams } from 'ish-core/utils/url-form-params'; import { loadMoreProducts, loadMoreProductsForParams, - loadPagesForMaster, setProductListingPageSize, setProductListingPages, setViewType, @@ -42,9 +38,7 @@ export class ProductListingEffects { @Inject(PRODUCT_LISTING_ITEMS_PER_PAGE) private itemsPerPage: number, @Inject(DEFAULT_PRODUCT_LISTING_VIEW_TYPE) private defaultViewType: ViewType, private actions$: Actions, - private store: Store, - private productListingMapper: ProductListingMapper, - private productMasterVariationsService: ProductMasterVariationsService + private store: Store ) {} initializePageSize$ = createEffect(() => @@ -94,6 +88,7 @@ export class ProductListingEffects { ? { ...stringToFormParams(params.filters), ...(id.type === 'search' ? { searchTerm: [id.value] } : {}), + ...(id.type === 'master' ? { MasterSKU: [id.value] } : {}), } : undefined; @@ -136,13 +131,7 @@ export class ProductListingEffects { if (viewAvailable) { return setProductListingPages({ id: { page, sorting, filters, ...id } }); } - if ( - filters && - // TODO: work-around for different products/hits-result without filters - (id.type !== 'search' || this.isSearchFor(filters.searchTerm, id)) && - // TODO: work-around for client side computation of master variations - ['search', 'category'].includes(id.type) - ) { + if (filters) { const searchParameter = filters; return loadProductsForFilter({ id: { ...id, filters }, searchParameter, page, sorting }); } else { @@ -152,7 +141,7 @@ export class ProductListingEffects { case 'search': return searchProducts({ searchTerm: id.value, page, sorting }); case 'master': - return loadPagesForMaster({ id, sorting, filters }); + return loadProductsForMaster({ masterSKU: id.value, page, sorting }); default: return; } @@ -170,13 +159,7 @@ export class ProductListingEffects { map(({ id, filters }) => ({ type: id.type, value: id.value, filters })), distinctUntilChanged(isEqual), map(({ type, value, filters }) => { - if ( - filters && - // TODO: work-around for different products/hits-result without filters - (type !== 'search' || this.isSearchFor(filters.searchTerm, { type, value })) && - // TODO: work-around for client side computation of master variations - ['search', 'category'].includes(type) - ) { + if (filters) { const searchParameter = filters; return applyFilter({ searchParameter }); } else { @@ -186,7 +169,7 @@ export class ProductListingEffects { case 'search': return loadFilterForSearch({ searchTerm: value }); case 'master': - return loadPagesForMaster({ id: { type, value }, sorting: undefined, filters }); + return loadFilterForMaster({ masterSKU: value }); default: return; } @@ -195,43 +178,4 @@ export class ProductListingEffects { whenTruthy() ) ); - - /** - * client side computation of master variations - * TODO: this is a work-around - */ - loadPagesForMaster$ = createEffect(() => - this.actions$.pipe( - ofType(loadPagesForMaster), - mapToPayload(), - switchMap(({ id, filters }) => - this.store.pipe( - select(getProduct, { sku: id.value }), - filter((p: Product) => ProductHelper.isSufficientlyLoaded(p, ProductCompletenessLevel.Detail)), - filter(ProductHelper.hasVariations), - filter(ProductHelper.isMasterProduct), - take(1), - mergeMap(product => { - const { - filterNavigation, - products, - } = this.productMasterVariationsService.getFiltersAndFilteredVariationsForMasterProduct(product, filters); - - return [ - setProductListingPages( - this.productListingMapper.createPages(products, id.type, id.value, { - filters: filters ? filters : undefined, - }) - ), - loadFilterSuccess({ filterNavigation }), - ]; - }) - ) - ) - ) - ); - - private isSearchFor(searchTerm: string[], id: { type: string; value: string }): boolean { - return id.type === 'search' && searchTerm && searchTerm.includes(id.value); - } } diff --git a/src/app/core/store/shopping/product-listing/product-listing.reducer.ts b/src/app/core/store/shopping/product-listing/product-listing.reducer.ts index 0a030dff95..4f2579aeef 100644 --- a/src/app/core/store/shopping/product-listing/product-listing.reducer.ts +++ b/src/app/core/store/shopping/product-listing/product-listing.reducer.ts @@ -4,9 +4,14 @@ import { createReducer, on } from '@ngrx/store'; import { ProductListingID, ProductListingType } from 'ish-core/models/product-listing/product-listing.model'; import { ViewType } from 'ish-core/models/viewtype/viewtype.types'; import { loadProductsForFilter } from 'ish-core/store/shopping/filter'; -import { loadProductsForCategory, loadProductsForCategoryFail } from 'ish-core/store/shopping/products'; +import { + loadProductsForCategory, + loadProductsForCategoryFail, + loadProductsForMaster, + loadProductsForMasterFail, +} from 'ish-core/store/shopping/products'; import { searchProducts, searchProductsFail } from 'ish-core/store/shopping/search'; -import { setLoadingOn } from 'ish-core/utils/ngrx-creators'; +import { setLoadingOn, unsetLoadingAndErrorOn } from 'ish-core/utils/ngrx-creators'; import { formParamsToString } from 'ish-core/utils/url-form-params'; import { setProductListingPageSize, setProductListingPages, setViewType } from './product-listing.actions'; @@ -78,8 +83,8 @@ export const productListingReducer = createReducer( itemsPerPage: action.payload.itemsPerPage, })), on(setViewType, (state: ProductListingState, action) => ({ ...state, viewType: action.payload.viewType })), - setLoadingOn(searchProducts, loadProductsForCategory, loadProductsForFilter), - on(searchProductsFail, loadProductsForCategoryFail, (state: ProductListingState) => ({ ...state, loading: false })), + setLoadingOn(searchProducts, loadProductsForCategory, loadProductsForFilter, loadProductsForMaster), + unsetLoadingAndErrorOn(searchProductsFail, loadProductsForCategoryFail, loadProductsForMasterFail), on(setProductListingPages, (state: ProductListingState, action) => { const pages = action.payload.pages || diff --git a/src/app/core/store/shopping/products/products.actions.ts b/src/app/core/store/shopping/products/products.actions.ts index d65af68e81..19f4dedef3 100644 --- a/src/app/core/store/shopping/products/products.actions.ts +++ b/src/app/core/store/shopping/products/products.actions.ts @@ -25,6 +25,16 @@ export const loadProductsForCategoryFail = createAction( httpError<{ categoryId: string }>() ); +export const loadProductsForMaster = createAction( + '[Products Internal] Load Products for Master', + payload<{ masterSKU: string; page?: number; sorting?: string }>() +); + +export const loadProductsForMasterFail = createAction( + '[Products API] Load Products for Master Fail', + httpError<{ masterSKU: string }>() +); + export const loadProductVariations = createAction( '[Products Internal] Load Product Variations', payload<{ sku: string }>() diff --git a/src/app/core/store/shopping/products/products.effects.ts b/src/app/core/store/shopping/products/products.effects.ts index b781fe51fb..86b5a4a047 100644 --- a/src/app/core/store/shopping/products/products.effects.ts +++ b/src/app/core/store/shopping/products/products.effects.ts @@ -54,6 +54,8 @@ import { loadProductVariationsSuccess, loadProductsForCategory, loadProductsForCategoryFail, + loadProductsForMaster, + loadProductsForMasterFail, loadRetailSetSuccess, } from './products.actions'; import { getBreadcrumbForProductPage, getProductEntities, getSelectedProduct } from './products.selectors'; @@ -131,6 +133,38 @@ export class ProductsEffects { ) ); + /** + * retrieve products for category incremental respecting paging + */ + loadProductsForMaster$ = createEffect(() => + this.actions$.pipe( + ofType(loadProductsForMaster), + mapToPayload(), + map(payload => ({ ...payload, page: payload.page ? payload.page : 1 })), + concatMap(({ masterSKU, page, sorting }) => + this.productsService.getProductsForMaster(masterSKU, page, sorting).pipe( + concatMap(({ total, products, sortKeys }) => [ + ...products.map(product => loadProductSuccess({ product })), + setProductListingPages( + this.productListingMapper.createPages( + products.map(p => p.sku), + 'master', + masterSKU, + { + startPage: page, + sortKeys, + sorting, + itemCount: total, + } + ) + ), + ]), + mapErrorToAction(loadProductsForMasterFail, { masterSKU }) + ) + ) + ) + ); + loadProductBundles$ = createEffect(() => this.actions$.pipe( ofType(loadProductSuccess), diff --git a/src/app/core/store/shopping/products/products.selectors.ts b/src/app/core/store/shopping/products/products.selectors.ts index 554c522ed2..8148e29172 100644 --- a/src/app/core/store/shopping/products/products.selectors.ts +++ b/src/app/core/store/shopping/products/products.selectors.ts @@ -19,6 +19,7 @@ import { Product, ProductCompletenessLevel, ProductHelper } from 'ish-core/model import { generateCategoryUrl } from 'ish-core/routing/category/category.route'; import { selectRouteParam } from 'ish-core/store/core/router'; import { getCategoryEntities, getCategoryTree, getSelectedCategory } from 'ish-core/store/shopping/categories'; +import { getAvailableFilter } from 'ish-core/store/shopping/filter'; import { getShoppingState } from 'ish-core/store/shopping/shopping-store'; import { productAdapter } from './products.reducer'; @@ -105,6 +106,13 @@ export const getSelectedProduct = createSelector( export const getProductVariationOptions = createSelector(getProduct, productToVariationOptions); +export const getProductVariationCount = createSelector( + getProduct, + getAvailableFilter, + (product, filters) => + ProductHelper.isMasterProduct(product) && ProductVariationHelper.productVariationCount(product, filters) +); + export const getSelectedProductVariationOptions = createSelector(getSelectedProduct, productToVariationOptions); export const getProductBundleParts = createSelector(getProductEntities, (entities, props: { sku: string }): { diff --git a/src/app/core/utils/url-form-params.spec.ts b/src/app/core/utils/url-form-params.spec.ts index 8ed56cc2ae..5b4714973c 100644 --- a/src/app/core/utils/url-form-params.spec.ts +++ b/src/app/core/utils/url-form-params.spec.ts @@ -1,4 +1,6 @@ -import { formParamsToString, stringToFormParams } from './url-form-params'; +import { HttpParams } from '@angular/common/http'; + +import { appendFormParamsToHttpParams, formParamsToString, stringToFormParams } from './url-form-params'; describe('Url Form Params', () => { describe('stringToFormParams', () => { @@ -106,4 +108,52 @@ describe('Url Form Params', () => { ).toMatchInlineSnapshot(`"bar=d_or_e&foo=c"`); }); }); + + describe('appendFormParamsToHttpParams', () => { + it('should return empty strings for falsy input', () => { + expect(appendFormParamsToHttpParams(undefined).toString()).toMatchInlineSnapshot(`""`); + expect(appendFormParamsToHttpParams({}).toString()).toMatchInlineSnapshot(`""`); + }); + + it('should return empty string values for falsy or empty values', () => { + expect(appendFormParamsToHttpParams({ test: undefined }).toString()).toMatchInlineSnapshot(`""`); + expect(appendFormParamsToHttpParams({ test: [] }).toString()).toMatchInlineSnapshot(`""`); + }); + + it('should handle complex examples for given input', () => { + expect( + appendFormParamsToHttpParams({ + bar: ['d', 'e'], + foo: ['c'], + test: ['a', 'b'], + foobar: [], + }).toString() + ).toMatchInlineSnapshot(`"bar=d,e&foo=c&test=a,b"`); + }); + + it('should append to existing params when merging form params', () => { + expect( + appendFormParamsToHttpParams( + { + bar: ['d', 'e'], + foo: ['c'], + }, + new HttpParams().set('dummy', 'test') + ).toString() + ).toMatchInlineSnapshot(`"dummy=test&bar=d,e&foo=c"`); + }); + + it('should respect the separator option when merging form params', () => { + expect( + appendFormParamsToHttpParams( + { + bar: ['d', 'e'], + foo: ['c'], + }, + new HttpParams(), + '_or_' + ).toString() + ).toMatchInlineSnapshot(`"bar=d_or_e&foo=c"`); + }); + }); }); diff --git a/src/app/core/utils/url-form-params.ts b/src/app/core/utils/url-form-params.ts index 9c40a0bd47..771e6aa4d7 100644 --- a/src/app/core/utils/url-form-params.ts +++ b/src/app/core/utils/url-form-params.ts @@ -1,3 +1,5 @@ +import { HttpParams } from '@angular/common/http'; + export interface URLFormParams { [facet: string]: string[]; } @@ -11,6 +13,18 @@ export function formParamsToString(object: URLFormParams, separator = ','): stri : ''; } +export function appendFormParamsToHttpParams( + object: URLFormParams, + params: HttpParams = new HttpParams(), + separator = ',' +): HttpParams { + return object + ? Object.entries(object) + .filter(([, value]) => Array.isArray(value) && value.length) + .reduce((p, [key, val]) => p.set(decodeURI(key), (val as string[]).map(decodeURI).join(separator)), params) + : params; +} + export function stringToFormParams(object: string, separator = ','): URLFormParams { return object ? object diff --git a/src/app/shared/components/filter/filter-navigation-badges/filter-navigation-badges.component.html b/src/app/shared/components/filter/filter-navigation-badges/filter-navigation-badges.component.html index c1064aecc3..78b2b8f48b 100644 --- a/src/app/shared/components/filter/filter-navigation-badges/filter-navigation-badges.component.html +++ b/src/app/shared/components/filter/filter-navigation-badges/filter-navigation-badges.component.html @@ -1,8 +1,8 @@
diff --git a/src/app/shared/components/filter/filter-navigation-badges/filter-navigation-badges.component.ts b/src/app/shared/components/filter/filter-navigation-badges/filter-navigation-badges.component.ts index 5872832318..1ad6875068 100644 --- a/src/app/shared/components/filter/filter-navigation-badges/filter-navigation-badges.component.ts +++ b/src/app/shared/components/filter/filter-navigation-badges/filter-navigation-badges.component.ts @@ -12,29 +12,26 @@ export class FilterNavigationBadgesComponent implements OnChanges { @Input() filterNavigation: FilterNavigation; @Output() applyFilter = new EventEmitter<{ searchParameter: URLFormParams }>(); @Output() clearFilters = new EventEmitter(); - selected: { searchParameter: URLFormParams; facetName: string; filterName: string }[]; + selected: { searchParameter: URLFormParams; displayName: string; filterName: string }[]; ngOnChanges() { - this.selected = - !this.filterNavigation || !this.filterNavigation.filter - ? [] - : this.filterNavigation.filter.reduce( - (acc, filterElement) => [ - ...acc, - ...filterElement.facets - .filter(facet => facet.selected) - .map(({ searchParameter, name }) => ({ - filterName: filterElement.name, - facetName: name, - searchParameter, - })), - ], - [] - ); + this.selected = this.filterNavigation?.filter?.reduce( + (acc, filterElement) => [ + ...acc, + ...filterElement.facets + .filter(facet => facet.selected) + .map(({ searchParameter, displayName }) => ({ + filterName: filterElement.name, + displayName, + searchParameter, + })), + ], + [] + ); } - apply(select: { searchParameter: URLFormParams; facetName: string; filterName: string }) { - this.applyFilter.emit({ searchParameter: select.searchParameter }); + apply(searchParameter: URLFormParams) { + this.applyFilter.emit({ searchParameter }); } clear() { diff --git a/src/app/shared/components/product/product-price/product-price.component.ts b/src/app/shared/components/product/product-price/product-price.component.ts index 067d6d069e..6710c59724 100644 --- a/src/app/shared/components/product/product-price/product-price.component.ts +++ b/src/app/shared/components/product/product-price/product-price.component.ts @@ -6,7 +6,7 @@ import { ProductPrices } from 'ish-core/models/product/product.model'; @Component({ selector: 'ish-product-price', templateUrl: './product-price.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, + changeDetection: ChangeDetectionStrategy.Default, }) export class ProductPriceComponent implements OnChanges { @Input() product: ProductPrices; diff --git a/src/app/shared/components/product/product-row/product-row.component.html b/src/app/shared/components/product/product-row/product-row.component.html index 9c1dcd47d7..d14cbedde4 100644 --- a/src/app/shared/components/product/product-row/product-row.component.html +++ b/src/app/shared/components/product/product-row/product-row.component.html @@ -1,7 +1,7 @@
- + @@ -11,9 +11,13 @@
- {{ - product.name - }} + {{ product.name }} @@ -135,7 +139,12 @@ diff --git a/src/app/shared/components/product/product-tile/product-tile.component.html b/src/app/shared/components/product/product-tile/product-tile.component.html index 999282fa53..0c783c41bc 100644 --- a/src/app/shared/components/product/product-tile/product-tile.component.html +++ b/src/app/shared/components/product/product-tile/product-tile.component.html @@ -1,14 +1,18 @@
- {{ - product.name - }} + {{ product.name }} @@ -35,7 +39,9 @@ (selectVariation)="variationSelected($event)" > - {{ variationCount }} {{ 'product.variations.text' | translate }} + {{ variationCount$ | async }} {{ 'product.variations.text' | translate }}
diff --git a/src/app/shared/components/product/product-tile/product-tile.component.spec.ts b/src/app/shared/components/product/product-tile/product-tile.component.spec.ts index fd36eb560d..91238e9ec6 100644 --- a/src/app/shared/components/product/product-tile/product-tile.component.spec.ts +++ b/src/app/shared/components/product/product-tile/product-tile.component.spec.ts @@ -2,7 +2,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; +import { instance, mock } from 'ts-mockito'; +import { ShoppingFacade } from 'ish-core/facades/shopping.facade'; import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { ProductView } from 'ish-core/models/product-view/product-view.model'; import { ProductRoutePipe } from 'ish-core/routing/product/product-route.pipe'; @@ -50,6 +52,7 @@ describe('Product Tile Component', () => { MockPipe(ProductRoutePipe), ProductTileComponent, ], + providers: [{ provide: ShoppingFacade, useFactory: () => instance(mock(ShoppingFacade)) }], }).compileComponents(); }); diff --git a/src/app/shared/components/product/product-tile/product-tile.component.ts b/src/app/shared/components/product/product-tile/product-tile.component.ts index d207534544..c4fdcfe92c 100644 --- a/src/app/shared/components/product/product-tile/product-tile.component.ts +++ b/src/app/shared/components/product/product-tile/product-tile.component.ts @@ -1,5 +1,7 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ShoppingFacade } from 'ish-core/facades/shopping.facade'; import { CategoryView } from 'ish-core/models/category-view/category-view.model'; import { VariationOptionGroup } from 'ish-core/models/product-variation/variation-option-group.model'; import { VariationSelection } from 'ish-core/models/product-variation/variation-selection.model'; @@ -28,7 +30,7 @@ export interface ProductTileComponentConfiguration { templateUrl: './product-tile.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ProductTileComponent { +export class ProductTileComponent implements OnChanges { @Input() configuration: Partial = {}; @Input() product: ProductView | VariationProductView | VariationProductMasterView; @Input() quantity: number; @@ -39,8 +41,16 @@ export class ProductTileComponent { @Output() productToBasket = new EventEmitter(); @Output() selectVariation = new EventEmitter<{ selection: VariationSelection; changedAttribute?: string }>(); + variationCount$: Observable; + isMasterProduct = ProductHelper.isMasterProduct; + constructor(private shoppingFacade: ShoppingFacade) {} + + ngOnChanges() { + this.variationCount$ = this.shoppingFacade.productVariationCount$(this.product?.sku); + } + addToBasket() { this.productToBasket.emit(this.quantity || this.product.minOrderQuantity); } @@ -52,13 +62,4 @@ export class ProductTileComponent { variationSelected(event: { selection: VariationSelection; changedAttribute?: string }) { this.selectVariation.emit(event); } - - get variationCount() { - return ( - this.product && - ProductHelper.isMasterProduct(this.product) && - this.product.variations() && - this.product.variations().length - ); - } }