Skip to content

Commit

Permalink
feat: fetch and display personalized category and product data (#1021)
Browse files Browse the repository at this point in the history
Closes #315

BREAKING CHANGES: Handling personalized REST calls for category and product data required some possibly breaking changes (see [Migrations / 2.0 to 2.1](https://github.com/intershop/intershop-pwa/blob/develop/docs/guides/migrations.md#20-to-21) for more details).

Co-authored-by: Marcel Eisentraut <meisentraut@intershop.de>
Co-authored-by: max.kless@googlemail.com <max.kless@googlemail.com>
  • Loading branch information
3 people authored Mar 7, 2022
1 parent 49497c9 commit fd08931
Show file tree
Hide file tree
Showing 27 changed files with 380 additions and 166 deletions.
7 changes: 7 additions & 0 deletions docs/guides/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ TestBed configuration arrays are sorted again as of 2.1 This means a lot of (sma
Simply run `ng lint --fix` in order to sort your arrays.
If you have a lot of migration changes, you might be required to run it more than once.

With the introduction of personalized REST calls for categories and products, data in the ngrx store runs the risk of not being up-to-date after a login or logout.
To fix this, a new `resetSubStatesOnActionsMeta` meta-reducer was introduced to remove potentially invalid data from the store.
If the removal of previous data from the store is not wanted this meta reducer should not be used in customized projects.
In addition a mechanism was introduced to trigger such personalized REST calls after loading the PGID if necessary.
This way of loading personalized data might need to be added to any custom implementations that potentially fetch personalized data.
To get an idea of the necessary mechanism search for the usage of `useCombinedObservableOnAction` and `personalizationStatusDetermined` in the source code.

## 1.4 to 2.0

Since [TSLint has been deprecated](https://blog.palantir.com/tslint-in-2019-1a144c2317a9) for a while now and Angular removed the TSLint support we had to migrate our project from TSLint to ESLint as well.
Expand Down
20 changes: 18 additions & 2 deletions src/app/core/facades/shopping.facade.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { debounce, filter, map, switchMap, tap } from 'rxjs/operators';
import { debounce, filter, map, pairwise, startWith, switchMap, tap } from 'rxjs/operators';

import { ProductListingID } from 'ish-core/models/product-listing/product-listing.model';
import { ProductCompletenessLevel, ProductHelper } from 'ish-core/models/product/product.model';
Expand Down Expand Up @@ -68,7 +68,10 @@ export class ShoppingFacade {
if (!uniqueId) {
this.store.dispatch(loadTopLevelCategories());
}
return this.store.pipe(select(getNavigationCategories(uniqueId)));
return this.store.pipe(
select(getNavigationCategories(uniqueId)),
filter(categories => !!categories?.length)
); // prevent to display an empty navigation bar after login/logout);
}

// PRODUCT
Expand All @@ -88,6 +91,19 @@ export class ShoppingFacade {
switchMap(plainSKU =>
this.store.pipe(
select(getProduct(plainSKU)),
startWith(undefined),
pairwise(),
tap(([prev, curr]) => {
if (
ProductHelper.isReadyForDisplay(prev, completenessLevel) &&
!ProductHelper.isReadyForDisplay(curr, completenessLevel)
) {
level === true
? this.store.dispatch(loadProduct({ sku: plainSKU }))
: this.store.dispatch(loadProductIfNotLoaded({ sku: plainSKU, level }));
}
}),
map(([, curr]) => curr),
filter(p => ProductHelper.isReadyForDisplay(p, completenessLevel))
)
)
Expand Down
2 changes: 1 addition & 1 deletion src/app/core/identity-provider/icm.identity-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ export class ICMIdentityProvider implements IdentityProvider {
}

triggerLogout(): TriggerReturnType {
this.store.dispatch(logoutUser());
this.apiTokenService.removeApiToken();
this.store.dispatch(logoutUser());
return this.store.pipe(
select(selectQueryParam('returnUrl')),
map(returnUrl => returnUrl || '/home'),
Expand Down
10 changes: 6 additions & 4 deletions src/app/core/services/categories/categories.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ describe('Categories Service', () => {
when(apiServiceMock.get('categories', anything())).thenReturn(
of([{ categoryPath: [{ id: 'blubb' }] }] as CategoryData[])
);
when(apiServiceMock.get('categories/dummyid')).thenReturn(of({ categoryPath: [{ id: 'blubb' }] } as CategoryData));
when(apiServiceMock.get('categories/dummyid/dummysubid')).thenReturn(
when(apiServiceMock.get('categories/dummyid', anything())).thenReturn(
of({ categoryPath: [{ id: 'blubb' }] } as CategoryData)
);
when(apiServiceMock.get('categories/dummyid/dummysubid', anything())).thenReturn(
of({ categoryPath: [{ id: 'blubb' }] } as CategoryData)
);
TestBed.configureTestingModule({
Expand Down Expand Up @@ -62,7 +64,7 @@ describe('Categories Service', () => {
it('should call underlying ApiService categories/id when asked to resolve a category by id', () => {
categoriesService.getCategory('dummyid');

verify(apiServiceMock.get('categories/dummyid')).once();
verify(apiServiceMock.get('categories/dummyid', anything())).once();
});

it('should return error when called with undefined', done => {
Expand Down Expand Up @@ -91,7 +93,7 @@ describe('Categories Service', () => {

it('should call underlying ApiService categories/id when asked to resolve a subcategory by id', () => {
categoriesService.getCategory('dummyid/dummysubid');
verify(apiServiceMock.get('categories/dummyid/dummysubid')).once();
verify(apiServiceMock.get('categories/dummyid/dummysubid', anything())).once();
});
});
});
22 changes: 12 additions & 10 deletions src/app/core/services/categories/categories.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,17 @@ export class CategoriesService {
return throwError(() => new Error('getCategory() called without categoryUniqueId'));
}

return this.apiService.get<CategoryData>(`categories/${CategoryHelper.getCategoryPath(categoryUniqueId)}`).pipe(
map(element => this.categoryMapper.fromData(element)),
// bump up completeness level as it won't get any better than this
tap(
tree =>
(tree.nodes[tree.categoryRefs[categoryUniqueId] ?? categoryUniqueId].completenessLevel =
CategoryCompletenessLevel.Max)
)
);
return this.apiService
.get<CategoryData>(`categories/${CategoryHelper.getCategoryPath(categoryUniqueId)}`, { sendSPGID: true })
.pipe(
map(element => this.categoryMapper.fromData(element)),
// bump up completeness level as it won't get any better than this
tap(
tree =>
(tree.nodes[tree.categoryRefs[categoryUniqueId] ?? categoryUniqueId].completenessLevel =
CategoryCompletenessLevel.Max)
)
);
}

/**
Expand All @@ -50,7 +52,7 @@ export class CategoriesService {
params = params.set('view', 'tree').set('limit', limit.toString()).set('omitHasOnlineProducts', 'true');
}

return this.apiService.get('categories', { params }).pipe(
return this.apiService.get('categories', { sendSPGID: true, params }).pipe(
unpackEnvelope<CategoryData>(),
map(categoriesData =>
categoriesData
Expand Down
7 changes: 5 additions & 2 deletions src/app/core/services/filter/filter.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,20 @@ describe('Filter Service', () => {
});

it("should get Filter data when 'getFilterForCategory' is called", done => {
when(apiService.get(anything())).thenReturn(of(filterMock));
when(apiService.get(anything(), 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(anything())).once();
verify(apiService.get(anything(), anything())).once();
expect(capture(apiService.get).last()).toMatchInlineSnapshot(`
Array [
"categories/A/B/productfilters",
Object {
"sendSPGID": true,
},
]
`);

Expand Down
10 changes: 7 additions & 3 deletions src/app/core/services/filter/filter.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,23 @@ export class FilterService {
getFilterForCategory(categoryUniqueId: string): Observable<FilterNavigation> {
const category = CategoryHelper.getCategoryPath(categoryUniqueId);
return this.apiService
.get<FilterNavigationData>(`categories/${category}/productfilters`)
.get<FilterNavigationData>(`categories/${category}/productfilters`, { sendSPGID: true })
.pipe(map(filter => this.filterNavigationMapper.fromData(filter)));
}

getFilterForSearch(searchTerm: string): Observable<FilterNavigation> {
return this.apiService
.get<FilterNavigationData>(`productfilters`, { params: new HttpParams().set('searchTerm', searchTerm) })
.get<FilterNavigationData>(`productfilters`, {
sendSPGID: true,
params: new HttpParams().set('searchTerm', searchTerm),
})
.pipe(map(filter => this.filterNavigationMapper.fromData(filter)));
}

getFilterForMaster(masterSKU: string): Observable<FilterNavigation> {
return this.apiService
.get<FilterNavigationData>(`productfilters`, {
sendSPGID: true,
params: new HttpParams().set('MasterSKU', masterSKU),
})
.pipe(map(filter => this.filterNavigationMapper.fromData(filter)));
Expand Down Expand Up @@ -84,7 +88,7 @@ export class FilterService {
total: number;
elements: ProductDataStub[];
sortableAttributes: { [id: string]: SortableAttributesType };
}>(resource, { params })
}>(resource, { params, sendSPGID: true })
.pipe(
map(x => ({
products: x.elements.map(stub => this.productMapper.fromStubData(stub)),
Expand Down
28 changes: 15 additions & 13 deletions src/app/core/services/products/products.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,20 +118,22 @@ describe('Products Service', () => {
});

it("should get product variations data when 'getProductVariations' is called", done => {
when(apiServiceMock.get(`products/${productSku}/variations`)).thenReturn(of({ elements: [] }));
when(apiServiceMock.get(`products/${productSku}/variations`, anything())).thenReturn(of({ elements: [] }));
productsService.getProductVariations(productSku).subscribe(() => {
verify(apiServiceMock.get(`products/${productSku}/variations`)).once();
verify(apiServiceMock.get(`products/${productSku}/variations`, anything())).once();
done();
});
});

it("should get all product variations data when 'getProductVariations' is called and more than 50 variations exist", done => {
const total = 156;
when(apiServiceMock.get(`products/${productSku}/variations`)).thenReturn(of({ elements: [], amount: 40, total }));
when(apiServiceMock.get(`products/${productSku}/variations`, anything())).thenReturn(of({ elements: [], total }));
when(apiServiceMock.get(`products/${productSku}/variations`, anything())).thenCall((_, opts) => {
return !opts.params ? of({ elements: [], amount: 40, total }) : of({ elements: [], total });
});

productsService.getProductVariations(productSku).subscribe(() => {
verify(apiServiceMock.get(`products/${productSku}/variations`)).once();
verify(apiServiceMock.get(`products/${productSku}/variations`, anything())).thrice();
verify(apiServiceMock.get(`products/${productSku}/variations`, anything())).times(4);
expect(capture<string, AvailableOptions>(apiServiceMock.get).byCallIndex(0)?.[1]?.params).toBeUndefined();
expect(
capture<string, AvailableOptions>(apiServiceMock.get).byCallIndex(1)?.[1]?.params?.toString()
).toMatchInlineSnapshot(`"amount=40&offset=40"`);
Expand All @@ -146,31 +148,31 @@ describe('Products Service', () => {
});

it("should get product bundles data when 'getProductBundles' is called", done => {
when(apiServiceMock.get(`products/${productSku}/bundles`)).thenReturn(of([]));
when(apiServiceMock.get(`products/${productSku}/bundles`, anything())).thenReturn(of([]));
productsService.getProductBundles(productSku).subscribe(() => {
verify(apiServiceMock.get(`products/${productSku}/bundles`)).once();
verify(apiServiceMock.get(`products/${productSku}/bundles`, anything())).once();
done();
});
});

it("should get retail set parts data when 'getRetailSetParts' is called", done => {
when(apiServiceMock.get(`products/${productSku}/partOfRetailSet`)).thenReturn(of([]));
when(apiServiceMock.get(`products/${productSku}/partOfRetailSet`, anything())).thenReturn(of([]));
productsService.getRetailSetParts(productSku).subscribe(() => {
verify(apiServiceMock.get(`products/${productSku}/partOfRetailSet`)).once();
verify(apiServiceMock.get(`products/${productSku}/partOfRetailSet`, anything())).once();
done();
});
});

it("should get product links data when 'getProductLinks' is called", done => {
when(apiServiceMock.get(`products/${productSku}/links`)).thenReturn(of([]));
when(apiServiceMock.get(`products/${productSku}/links`, anything())).thenReturn(of([]));
productsService.getProductLinks(productSku).subscribe(() => {
verify(apiServiceMock.get(`products/${productSku}/links`)).once();
verify(apiServiceMock.get(`products/${productSku}/links`, anything())).once();
done();
});
});

it("should get map product links data when 'getProductLinks' is called", done => {
when(apiServiceMock.get(`products/${productSku}/links`)).thenReturn(
when(apiServiceMock.get(`products/${productSku}/links`, anything())).thenReturn(
of({
elements: [
{
Expand Down
81 changes: 42 additions & 39 deletions src/app/core/services/products/products.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class ProductsService {
const params = new HttpParams().set('allImages', 'true');

return this.apiService
.get<ProductData>(`products/${sku}`, { params })
.get<ProductData>(`products/${sku}`, { sendSPGID: true, params })
.pipe(map(element => this.productMapper.fromData(element)));
}

Expand Down Expand Up @@ -85,7 +85,7 @@ export class ProductsService {
sortableAttributes: { [id: string]: SortableAttributesType };
categoryUniqueId: string;
total: number;
}>(`categories/${CategoryHelper.getCategoryPath(categoryUniqueId)}/products`, { params })
}>(`categories/${CategoryHelper.getCategoryPath(categoryUniqueId)}/products`, { sendSPGID: true, params })
.pipe(
map(response => ({
products: response.elements.map((element: ProductDataStub) => this.productMapper.fromStubData(element)),
Expand Down Expand Up @@ -138,7 +138,7 @@ export class ProductsService {
sortKeys: string[];
sortableAttributes: { [id: string]: SortableAttributesType };
total: number;
}>('products', { params })
}>('products', { sendSPGID: true, params })
.pipe(
map(response => ({
products: response.elements.map(element => this.productMapper.fromStubData(element)),
Expand Down Expand Up @@ -182,7 +182,7 @@ export class ProductsService {
elements: ProductDataStub[];
sortableAttributes: { [id: string]: SortableAttributesType };
total: number;
}>('products', { params })
}>('products', { sendSPGID: true, params })
.pipe(
map(response => ({
products: response.elements.map(element => this.productMapper.fromStubData(element)) as Product[],
Expand Down Expand Up @@ -217,38 +217,41 @@ export class ProductsService {
return throwError(() => new Error('getProductVariations() called without a sku'));
}

return this.apiService.get<{ elements: Link[]; total: number; amount: number }>(`products/${sku}/variations`).pipe(
switchMap(resp =>
!resp.total
? of(resp.elements)
: of(resp).pipe(
mergeMap(res => {
const amount = res.amount;
const chunks = Math.ceil((res.total - amount) / amount);
return from(
range(1, chunks + 1)
.map(i => [i * amount, Math.min(amount, res.total - amount * i)])
.map(([offset, length]) =>
this.apiService
.get<{ elements: Link[] }>(`products/${sku}/variations`, {
params: new HttpParams().set('amount', length).set('offset', offset),
})
.pipe(mapToProperty('elements'))
)
);
}),
mergeMap(identity, 2),
toArray(),
map(resp2 => [...resp.elements, ...flatten(resp2)])
)
),
map((links: ProductVariationLink[]) => ({
products: links.map(link => this.productMapper.fromVariationLink(link, sku)),
defaultVariation: ProductMapper.findDefaultVariation(links),
})),
map(data => ({ ...data, masterProduct: ProductMapper.constructMasterStub(sku, data.products) })),
defaultIfEmpty({ products: [], defaultVariation: undefined, masterProduct: undefined })
);
return this.apiService
.get<{ elements: Link[]; total: number; amount: number }>(`products/${sku}/variations`, { sendSPGID: true })
.pipe(
switchMap(resp =>
!resp.total
? of(resp.elements)
: of(resp).pipe(
mergeMap(res => {
const amount = res.amount;
const chunks = Math.ceil((res.total - amount) / amount);
return from(
range(1, chunks + 1)
.map(i => [i * amount, Math.min(amount, res.total - amount * i)])
.map(([offset, length]) =>
this.apiService
.get<{ elements: Link[] }>(`products/${sku}/variations`, {
sendSPGID: true,
params: new HttpParams().set('amount', length).set('offset', offset),
})
.pipe(mapToProperty('elements'))
)
);
}),
mergeMap(identity, 2),
toArray(),
map(resp2 => [...resp.elements, ...flatten(resp2)])
)
),
map((links: ProductVariationLink[]) => ({
products: links.map(link => this.productMapper.fromVariationLink(link, sku)),
defaultVariation: ProductMapper.findDefaultVariation(links),
})),
map(data => ({ ...data, masterProduct: ProductMapper.constructMasterStub(sku, data.products) })),
defaultIfEmpty({ products: [], defaultVariation: undefined, masterProduct: undefined })
);
}

/**
Expand All @@ -259,7 +262,7 @@ export class ProductsService {
return throwError(() => new Error('getProductBundles() called without a sku'));
}

return this.apiService.get(`products/${sku}/bundles`).pipe(
return this.apiService.get(`products/${sku}/bundles`, { sendSPGID: true }).pipe(
unpackEnvelope<Link>(),
map(links => ({
stubs: links.map(link => this.productMapper.fromLink(link)),
Expand All @@ -276,15 +279,15 @@ export class ProductsService {
return throwError(() => new Error('getRetailSetParts() called without a sku'));
}

return this.apiService.get(`products/${sku}/partOfRetailSet`).pipe(
return this.apiService.get(`products/${sku}/partOfRetailSet`, { sendSPGID: true }).pipe(
unpackEnvelope<Link>(),
map(links => links.map(link => this.productMapper.fromRetailSetLink(link))),
defaultIfEmpty([])
);
}

getProductLinks(sku: string): Observable<ProductLinksDictionary> {
return this.apiService.get(`products/${sku}/links`).pipe(
return this.apiService.get(`products/${sku}/links`, { sendSPGID: true }).pipe(
unpackEnvelope<{ linkType: string; categoryLinks: Link[]; productLinks: Link[] }>(),
map(links =>
links.reduce(
Expand Down
Loading

0 comments on commit fd08931

Please sign in to comment.