Skip to content

Commit

Permalink
feat: make products service default attrs easily overridable (#1098)
Browse files Browse the repository at this point in the history
* make stub attributes overridable
* move getFilteredProducts from FilterService to ProductsService

BREAKING CHANGE: The method `getFilteredProducts´ was moved from `FilterService` to `ProductsService`.
  • Loading branch information
dhhyi authored May 2, 2022
1 parent bf81099 commit 78ef2e1
Show file tree
Hide file tree
Showing 13 changed files with 189 additions and 223 deletions.
5 changes: 4 additions & 1 deletion docs/guides/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ kb_sync_latest_only

The 'contact us' functionality has been moved into an extension and we have introduced the feature toggle `contactUs` in the `environment.model.ts` that is switched on by default.

The `getFilteredProducts` method has been moved from the `FilterService` to the `ProductsService`, since the `/products` API is used.
Together with this change the default products attributes for product listings are externalized and and are now easily overridable.

## 2.1 to 2.2

The PWA 2.2 contains an Angular update to version 13.3.0 and many other dependencies updates.<br/>
Expand Down Expand Up @@ -42,7 +45,7 @@ The compare products functionality was moved into an extension.
The already existing `compare` feature toggle works as before but the compare components integration changed to lazy components, e.g. `<ish-product-add-to-compare displayType="icon"></ish-product-add-to-compare>` to `<ish-lazy-product-add-to-compare displayType="icon"></ish-lazy-product-add-to-compare>`.
For other compare components check the compare-exports.module.ts file.

# 2.0 to 2.1
## 2.0 to 2.1

The recently viewed products functionality was moved into an extension.
The already existing `recently` feature toggle works as before but the recently viewed component integration changed from `<ish-recently-viewed *ishFeature="'recently'"></ish-recently-viewed>` to `<ish-lazy-recently-viewed></ish-lazy-recently-viewed>`.
Expand Down
36 changes: 1 addition & 35 deletions src/app/core/services/filter/filter.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,6 @@ describe('Filter Service', () => {
let filterService: FilterService;
let appFacadeMock: AppFacade;

const productsMock = {
elements: [
{ uri: 'products/123', attributes: [{ name: 'sku', value: '123' }] },
{ uri: 'products/234', attributes: [{ name: 'sku', value: '234' }] },
],
total: 2,
};
const filterMock = {
elements: [
{
Expand All @@ -43,10 +36,7 @@ describe('Filter Service', () => {

TestBed.configureTestingModule({
imports: [CoreStoreModule.forTesting(['configuration'])],
providers: [
{ provide: ApiService, useFactory: () => instance(apiService) },
{ provide: AppFacade, useFactory: () => instance(appFacadeMock) },
],
providers: [{ provide: ApiService, useFactory: () => instance(apiService) }],
});
filterService = TestBed.inject(FilterService);

Expand Down Expand Up @@ -95,28 +85,4 @@ describe('Filter Service', () => {
done();
});
});

it("should get Product SKUs when 'getFilteredProducts' is called", done => {
when(apiService.get(anything(), anything())).thenReturn(of(productsMock));

filterService.getFilteredProducts({ SearchParameter: ['b'] } as URLFormParams, 2).subscribe(data => {
expect(data?.products?.map(p => p.sku)).toMatchInlineSnapshot(`
Array [
"123",
"234",
]
`);
expect(data?.total).toMatchInlineSnapshot(`2`);
expect(data?.sortableAttributes).toMatchInlineSnapshot(`Array []`);

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,availability,manufacturer,image,minOrderQuantity,maxOrderQuantity,stepOrderQuantity,inStock,promotions,packingUnit,mastered,productMaster,productMasterSKU,roundedAverageRating,retailSet,defaultCategory&attributeGroup=PRODUCT_LABEL_ATTRIBUTES&returnSortKeys=true&SearchParameter=b"`
);

done();
});
});
});
71 changes: 2 additions & 69 deletions src/app/core/services/filter/filter.service.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,19 @@
import { HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map, withLatestFrom } from 'rxjs/operators';
import { map } from 'rxjs/operators';

import { AppFacade } from 'ish-core/facades/app.facade';
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 { SortableAttributesType } from 'ish-core/models/product-listing/product-listing.model';
import { ProductDataStub } from 'ish-core/models/product/product.interface';
import { ProductMapper } from 'ish-core/models/product/product.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 { omit } from 'ish-core/utils/functions';
import { URLFormParams, appendFormParamsToHttpParams } from 'ish-core/utils/url-form-params';

@Injectable({ providedIn: 'root' })
export class FilterService {
constructor(
private apiService: ApiService,
private filterNavigationMapper: FilterNavigationMapper,
private productMapper: ProductMapper,
private appFacade: AppFacade
) {}
constructor(private apiService: ApiService, private filterNavigationMapper: FilterNavigationMapper) {}

getFilterForCategory(categoryUniqueId: string): Observable<FilterNavigation> {
const category = CategoryHelper.getCategoryPath(categoryUniqueId);
Expand Down Expand Up @@ -63,59 +51,4 @@ export class FilterService {
.get<FilterNavigationData>(resource, { params })
.pipe(map(filter => this.filterNavigationMapper.fromData(filter)));
}

getFilteredProducts(
searchParameter: URLFormParams,
amount: number,
sortKey?: string,
offset = 0
): Observable<{ total: number; products: Partial<Product>[]; sortableAttributes: SortableAttributesType[] }> {
let params = new HttpParams()
.set('amount', amount ? amount.toString() : '')
.set('offset', offset.toString())
.set('attrs', ProductsService.STUB_ATTRS)
.set('attributeGroup', AttributeGroupTypes.ProductLabelAttributes)
.set('returnSortKeys', 'true');
if (sortKey) {
params = params.set('sortKey', sortKey);
}
params = appendFormParamsToHttpParams(omit(searchParameter, 'category'), params);

const resource = searchParameter.category ? `categories/${searchParameter.category[0]}/products` : 'products';

return this.apiService
.get<{
total: number;
elements: ProductDataStub[];
sortableAttributes: { [id: string]: SortableAttributesType };
}>(resource, { params, sendSPGID: true })
.pipe(
map(x => ({
products: x.elements.map(stub => this.productMapper.fromStubData(stub)),
total: x.total,
sortableAttributes: Object.values(x.sortableAttributes || {}),
})),
withLatestFrom(
this.appFacade.serverSetting$<boolean>('preferences.ChannelPreferences.EnableAdvancedVariationHandling')
),
map(([{ products, sortableAttributes, total }, advancedVariationHandling]) => ({
products: params.has('MasterSKU') ? products : this.postProcessMasters(products, advancedVariationHandling),
sortableAttributes,
total,
}))
);
}

/**
* exchange single-return variation products to master products for B2B
* TODO: this is a work-around
*/
private postProcessMasters(products: Partial<Product>[], advancedVariationHandling: boolean): Product[] {
if (advancedVariationHandling) {
return products.map(p =>
ProductHelper.isVariationProduct(p) ? { sku: p.productMasterSKU, completenessLevel: 0 } : p
) as Product[];
}
return products as Product[];
}
}
1 change: 1 addition & 0 deletions src/app/core/services/products/products-list-attributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'sku,availability,manufacturer,image,minOrderQuantity,maxOrderQuantity,stepOrderQuantity,inStock,promotions,packingUnit,mastered,productMaster,productMasterSKU,roundedAverageRating,retailSet,defaultCategory';
33 changes: 33 additions & 0 deletions src/app/core/services/products/products.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ApiService, AvailableOptions } from 'ish-core/services/api/api.service'
import { CoreStoreModule } from 'ish-core/store/core/core-store.module';
import { ProductListingEffects } from 'ish-core/store/shopping/product-listing/product-listing.effects';
import { ShoppingStoreModule } from 'ish-core/store/shopping/shopping-store.module';
import { URLFormParams } from 'ish-core/utils/url-form-params';

import { ProductsService } from './products.service';

Expand Down Expand Up @@ -226,4 +227,36 @@ describe('Products Service', () => {
done();
});
});

it("should get Product SKUs when 'getFilteredProducts' is called", done => {
when(apiServiceMock.get(anything(), anything())).thenReturn(
of({
elements: [
{ uri: 'products/123', attributes: [{ name: 'sku', value: '123' }] },
{ uri: 'products/234', attributes: [{ name: 'sku', value: '234' }] },
],
total: 2,
})
);

productsService.getFilteredProducts({ SearchParameter: ['b'] } as URLFormParams, 2).subscribe(data => {
expect(data?.products?.map(p => p.sku)).toMatchInlineSnapshot(`
Array [
"123",
"234",
]
`);
expect(data?.total).toMatchInlineSnapshot(`2`);
expect(data?.sortableAttributes).toMatchInlineSnapshot(`Array []`);

verify(apiServiceMock.get(anything(), anything())).once();
const [resource, options] = capture<string, AvailableOptions>(apiServiceMock.get).last();
expect(resource).toMatchInlineSnapshot(`"products"`);
expect(options?.params.get('SearchParameter')).toMatchInlineSnapshot(`"b"`);
expect(options?.params.get('amount')).toMatchInlineSnapshot(`"2"`);
expect(options?.params.get('offset')).toMatchInlineSnapshot(`"0"`);

done();
});
});
});
55 changes: 49 additions & 6 deletions src/app/core/services/products/products.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,17 @@ import {
VariationProductMaster,
} from 'ish-core/models/product/product.model';
import { ApiService, unpackEnvelope } from 'ish-core/services/api/api.service';
import { omit } from 'ish-core/utils/functions';
import { mapToProperty } from 'ish-core/utils/operators';
import { URLFormParams, appendFormParamsToHttpParams } from 'ish-core/utils/url-form-params';

import STUB_ATTRS from './products-list-attributes';

/**
* The Products Service handles the interaction with the 'products' REST API.
*/
@Injectable({ providedIn: 'root' })
export class ProductsService {
static STUB_ATTRS =
'sku,availability,manufacturer,image,minOrderQuantity,maxOrderQuantity,stepOrderQuantity,inStock,promotions,packingUnit,mastered,productMaster,productMasterSKU,roundedAverageRating,retailSet,defaultCategory';

constructor(private apiService: ApiService, private productMapper: ProductMapper, private appFacade: AppFacade) {}

/**
Expand Down Expand Up @@ -69,7 +70,7 @@ export class ProductsService {
}

let params = new HttpParams()
.set('attrs', ProductsService.STUB_ATTRS)
.set('attrs', STUB_ATTRS)
.set('attributeGroup', AttributeGroupTypes.ProductLabelAttributes)
.set('amount', amount.toString())
.set('offset', offset.toString())
Expand Down Expand Up @@ -125,7 +126,7 @@ export class ProductsService {
.set('searchTerm', searchTerm)
.set('amount', amount.toString())
.set('offset', offset.toString())
.set('attrs', ProductsService.STUB_ATTRS)
.set('attrs', STUB_ATTRS)
.set('attributeGroup', AttributeGroupTypes.ProductLabelAttributes)
.set('returnSortKeys', 'true');
if (sortKey) {
Expand Down Expand Up @@ -170,7 +171,7 @@ export class ProductsService {
.set('MasterSKU', masterSKU)
.set('amount', amount.toString())
.set('offset', offset.toString())
.set('attrs', ProductsService.STUB_ATTRS)
.set('attrs', STUB_ATTRS)
.set('attributeGroup', AttributeGroupTypes.ProductLabelAttributes)
.set('returnSortKeys', 'true');
if (sortKey) {
Expand All @@ -192,6 +193,48 @@ export class ProductsService {
);
}

getFilteredProducts(
searchParameter: URLFormParams,
amount: number,
sortKey?: string,
offset = 0
): Observable<{ total: number; products: Partial<Product>[]; sortableAttributes: SortableAttributesType[] }> {
let params = new HttpParams()
.set('amount', amount ? amount.toString() : '')
.set('offset', offset.toString())
.set('attrs', STUB_ATTRS)
.set('attributeGroup', AttributeGroupTypes.ProductLabelAttributes)
.set('returnSortKeys', 'true');
if (sortKey) {
params = params.set('sortKey', sortKey);
}
params = appendFormParamsToHttpParams(omit(searchParameter, 'category'), params);

const resource = searchParameter.category ? `categories/${searchParameter.category[0]}/products` : 'products';

return this.apiService
.get<{
total: number;
elements: ProductDataStub[];
sortableAttributes: { [id: string]: SortableAttributesType };
}>(resource, { params, sendSPGID: true })
.pipe(
map(x => ({
products: x.elements.map(stub => this.productMapper.fromStubData(stub)),
total: x.total,
sortableAttributes: Object.values(x.sortableAttributes || {}),
})),
withLatestFrom(
this.appFacade.serverSetting$<boolean>('preferences.ChannelPreferences.EnableAdvancedVariationHandling')
),
map(([{ products, sortableAttributes, total }, advancedVariationHandling]) => ({
products: params.has('MasterSKU') ? products : this.postProcessMasters(products, advancedVariationHandling),
sortableAttributes,
total,
}))
);
}

/**
* exchange single-return variation products to master products for B2B
* TODO: this is a work-around
Expand Down
6 changes: 3 additions & 3 deletions src/app/core/store/content/content-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { anything, capture, instance, mock, spy, verify, when } from 'ts-mockito
import { ContentPageletEntryPoint } from 'ish-core/models/content-pagelet-entry-point/content-pagelet-entry-point.model';
import { ContentPagelet } from 'ish-core/models/content-pagelet/content-pagelet.model';
import { CMSService } from 'ish-core/services/cms/cms.service';
import { FilterService } from 'ish-core/services/filter/filter.service';
import { ProductsService } from 'ish-core/services/products/products.service';
import { CoreStoreModule } from 'ish-core/store/core/core-store.module';
import { whenTruthy } from 'ish-core/utils/operators';

Expand All @@ -27,7 +27,7 @@ describe('Content Store', () => {

beforeEach(() => {
const cmsService = mock(CMSService);
const filterService = mock(FilterService);
const productsService = mock(ProductsService);
when(cmsService.getContentInclude('id')).thenReturn(
of({ include: { ...include }, pagelets: [{ ...pagelet, id: '1' }] })
);
Expand All @@ -36,7 +36,7 @@ describe('Content Store', () => {
imports: [ContentStoreModule, CoreStoreModule.forTesting([], true)],
providers: [
{ provide: CMSService, useFactory: () => instance(cmsService) },
{ provide: FilterService, useFactory: () => instance(filterService) },
{ provide: ProductsService, useFactory: () => instance(productsService) },
],
});

Expand Down
10 changes: 5 additions & 5 deletions src/app/core/store/content/parameters/parameters.effects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Observable, of } from 'rxjs';
import { anything, instance, mock, when } from 'ts-mockito';

import { Product } from 'ish-core/models/product/product.model';
import { FilterService } from 'ish-core/services/filter/filter.service';
import { ProductsService } from 'ish-core/services/products/products.service';
import { loadProductSuccess } from 'ish-core/store/shopping/products';
import { URLFormParams } from 'ish-core/utils/url-form-params';

Expand All @@ -16,13 +16,13 @@ import { ParametersEffects } from './parameters.effects';
describe('Parameters Effects', () => {
let actions$: Observable<Action>;
let effects: ParametersEffects;
let filterServiceMock: FilterService;
let productsServiceMock: ProductsService;

beforeEach(() => {
filterServiceMock = mock(FilterService);
productsServiceMock = mock(ProductsService);
TestBed.configureTestingModule({
providers: [
{ provide: FilterService, useFactory: () => instance(filterServiceMock) },
{ provide: ProductsService, useFactory: () => instance(productsServiceMock) },
ParametersEffects,
provideMockActions(() => actions$),
],
Expand All @@ -33,7 +33,7 @@ describe('Parameters Effects', () => {

describe('loadParameters$', () => {
it('should dispatch multiple actions when getFilteredProducts service is succesful', () => {
when(filterServiceMock.getFilteredProducts(anything(), anything())).thenReturn(
when(productsServiceMock.getFilteredProducts(anything(), anything())).thenReturn(
of({ total: 1, products: [{ name: 'test', sku: 'sku' } as Product], sortableAttributes: [] })
);
const action = loadParametersProductListFilter({
Expand Down
6 changes: 3 additions & 3 deletions src/app/core/store/content/parameters/parameters.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Actions, createEffect, ofType } from '@ngrx/effects';
import { concatMap, mergeMap } from 'rxjs/operators';

import { Product } from 'ish-core/models/product/product.model';
import { FilterService } from 'ish-core/services/filter/filter.service';
import { ProductsService } from 'ish-core/services/products/products.service';
import { loadProductSuccess } from 'ish-core/store/shopping/products';
import { mapErrorToAction, mapToPayload } from 'ish-core/utils/operators';

Expand All @@ -15,14 +15,14 @@ import {

@Injectable()
export class ParametersEffects {
constructor(private actions$: Actions, private filterService: FilterService) {}
constructor(private actions$: Actions, private productsService: ProductsService) {}

loadParametersProductListFilter$ = createEffect(() =>
this.actions$.pipe(
ofType(loadParametersProductListFilter),
mapToPayload(),
concatMap(({ id, searchParameter, amount }) =>
this.filterService.getFilteredProducts(searchParameter, amount).pipe(
this.productsService.getFilteredProducts(searchParameter, amount).pipe(
mergeMap(({ products }) => [
...products.map((product: Product) => loadProductSuccess({ product })),
loadParametersProductListFilterSuccess({ id, productList: products.map(p => p.sku) }),
Expand Down
Loading

0 comments on commit 78ef2e1

Please sign in to comment.