Skip to content

Commit

Permalink
CXSPA-8150:adapt Product Carousel in SPA to use enhanced Products Sea…
Browse files Browse the repository at this point in the history
…rch OCC API (#19125)
  • Loading branch information
davidabap authored Aug 13, 2024
1 parent 1cf2f95 commit 31c3de9
Show file tree
Hide file tree
Showing 25 changed files with 929 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,26 @@ export interface FeatureTogglesInterface {
*/
storeFrontLibCardParagraphTruncated?: boolean;

/**
* When enabled, the batch API is used `ProductCarouselComponent` to load products. It increases the component's performance.
*
* _NOTE_: When flag is enabled, custom OCC config for the `productSearch` endpoint has to be adjusted to have an object representation:
* ```js
* backend: {
* occ: {
* endpoints: {
* productSearch: {
* default: '...',
* carousel: '...',
* carouselMinimal: '...',
* },
* },
* },
* }
* ```
*/
useProductCarouselBatchApi?: boolean;

/**
* In `ConfiguratorAttributeDropDownComponent`, `ConfiguratorAttributeSingleSelectionImageComponent`
* and in 'ConfiguratorAttributeMultiSelectionImageComponent' some HTML changes were done
Expand Down Expand Up @@ -455,6 +475,7 @@ export const defaultFeatureToggles: Required<FeatureTogglesInterface> = {
recentSearches: false,
pdfInvoicesSortByInvoiceDate: false,
storeFrontLibCardParagraphTruncated: false,
useProductCarouselBatchApi: false,
productConfiguratorAttributeTypesV2: false,
productConfiguratorDeltaRendering: false,
a11yRequiredAsterisks: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,14 @@ export const defaultOccProductConfig: OccConfig = {
productReferences:
'products/${productCode}/references?fields=DEFAULT,references(target(images(FULL)))',
/* eslint-disable max-len */
productSearch:
'products/search?fields=products(code,name,summary,configurable,configuratorType,multidimensional,price(FULL),images(DEFAULT),stock(FULL),averageRating,variantOptions,baseProduct),facets,breadcrumbs,pagination(DEFAULT),sorts(DEFAULT),freeTextSearch,currentQuery,keywordRedirectUrl',
productSearch: {
default:
'products/search?fields=products(code,name,summary,configurable,configuratorType,multidimensional,price(FULL),images(DEFAULT),stock(FULL),averageRating,variantOptions,baseProduct),facets,breadcrumbs,pagination(DEFAULT),sorts(DEFAULT),freeTextSearch,currentQuery,keywordRedirectUrl',
carousel:
'products/search?fields=products(code,purchasable,name,summary,price(formattedValue),stock(DEFAULT),images(DEFAULT,galleryIndex),baseProduct)',
carouselMinimal:
'products/search?fields=products(code,name,price(formattedValue),images(DEFAULT),baseProduct)',
},
/* eslint-enable */
productSuggestions: 'products/suggestions',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
PRODUCT_SEARCH_PAGE_NORMALIZER,
PRODUCT_SUGGESTION_NORMALIZER,
} from '@spartacus/core';
import { of } from 'rxjs';
import { ProductSearchPage } from '../../../model/product-search.model';
import { SearchConfig } from '../../../product/model/search-config';
import { Occ } from '../../occ-models/occ.models';
Expand All @@ -34,6 +35,7 @@ const suggestionList: Occ.SuggestionList = { suggestions: [{ value: 'test' }] };
const mockSearchConfig: SearchConfig = {
pageSize: 5,
};
const scope = 'default';

describe('OccProductSearchAdapter', () => {
let service: OccProductSearchAdapter;
Expand Down Expand Up @@ -73,7 +75,7 @@ describe('OccProductSearchAdapter', () => {

describe('query text search', () => {
it('should return search results for given query text', () => {
service.search(queryText, mockSearchConfig).subscribe((result) => {
service.search(queryText, mockSearchConfig, scope).subscribe((result) => {
expect(result).toEqual(searchResults);
});

Expand All @@ -91,6 +93,7 @@ describe('OccProductSearchAdapter', () => {
query: queryText,
pageSize: mockSearchConfig.pageSize,
},
scope: scope,
});
mockReq.flush(searchResults);
});
Expand Down Expand Up @@ -149,4 +152,57 @@ describe('OccProductSearchAdapter', () => {
);
});
});

describe('searchByCodes', () => {
const mockProductsFromCodes: Array<{ code: string }> = [
{ code: '123' },
{ code: '456' },
];
const mockSearchConfigFromCodes = {
filters: 'code:123,456',
pageSize: 100,
};

it('should return products for given codes', () => {
spyOn(service, 'search').and.returnValue(
of({ products: mockProductsFromCodes })
);

service.searchByCodes(['123', '456']).subscribe((result) => {
expect(result.products).toEqual(mockProductsFromCodes);
});

expect(service.search).toHaveBeenCalledWith(
'',
mockSearchConfigFromCodes,
undefined
);
});

it('should handle empty input', () => {
spyOn(service, 'search');
service.searchByCodes([]).subscribe((result) => {
expect(result.products).toEqual([]);
});

expect(service.search).not.toHaveBeenCalled();
});

it('should handle chunking correctly', () => {
const largeArray = Array.from({ length: 250 }, (_, i) => i.toString());
const chunkedProducts = largeArray.map((code) => ({ code }));

spyOn(service, 'search').and.callFake((_, config) => {
const codes = config.filters.split(':')[1].split(',');
return of({ products: codes.map((code) => ({ code })) });
});

service.searchByCodes(largeArray).subscribe((result) => {
expect(result.products.length).toBe(250);
expect(result.products).toEqual(chunkedProducts);
});

expect(service.search).toHaveBeenCalledTimes(3);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

import { HttpClient, HttpContext } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { forkJoin, Observable, of } from 'rxjs';
import { Product } from '../../../model';
import { map, tap } from 'rxjs/operators';
import {
ProductSearchPage,
Expand Down Expand Up @@ -41,14 +42,15 @@ export class OccProductSearchAdapter implements ProductSearchAdapter {

search(
query: string,
searchConfig: SearchConfig = this.DEFAULT_SEARCH_CONFIG
searchConfig: SearchConfig = this.DEFAULT_SEARCH_CONFIG,
scope?: string
): Observable<ProductSearchPage> {
const context = new HttpContext().set(OCC_HTTP_TOKEN, {
sendUserIdAsHeader: true,
});

return this.http
.get(this.getSearchEndpoint(query, searchConfig), { context })
.get(this.getSearchEndpoint(query, searchConfig, scope), { context })
.pipe(
this.converter.pipeable(PRODUCT_SEARCH_PAGE_NORMALIZER),
tap(
Expand All @@ -59,6 +61,42 @@ export class OccProductSearchAdapter implements ProductSearchAdapter {
);
}

searchByCodes(
codes: string[],
scope?: string
): Observable<{ products: Product[] }> {
if (codes.length === 0) {
return of({ products: [] });
}

const CHUNK_SIZE = 100; // Max limit of ProductSearch OCC
const codesChunks = []; //split array of codes into chunks of max size

for (let i = 0; i < codes.length; i += CHUNK_SIZE) {
codesChunks.push(codes.slice(i, i + CHUNK_SIZE));
}

const chunksResults$: Observable<{ products: Product[] }>[] =
codesChunks.map((codesChunk) => {
const searchConfig: SearchConfig = {
filters: `code:${codesChunk.join(',')}`,
pageSize: CHUNK_SIZE,
};

return this.search('', searchConfig, scope).pipe(
map((productSearchPage) => ({
products: productSearchPage?.products ?? [],
}))
);
});

return forkJoin(chunksResults$).pipe(
map((chunksResults) => ({
products: chunksResults.flatMap((chunkResult) => chunkResult.products),
}))
);
}

loadSuggestions(
term: string,
pageSize: number = 3
Expand All @@ -75,10 +113,12 @@ export class OccProductSearchAdapter implements ProductSearchAdapter {

protected getSearchEndpoint(
query: string,
searchConfig: SearchConfig
searchConfig: SearchConfig,
scope?: string
): string {
return this.occEndpoints.buildUrl('productSearch', {
queryParams: { query, ...searchConfig },
scope,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,20 @@ import {
Suggestion,
ProductSearchPage,
} from '../../../model/product-search.model';
import { Product } from '../../../model';

export abstract class ProductSearchAdapter {
abstract search(
query: string,
searchConfig?: SearchConfig
searchConfig?: SearchConfig,
scope?: string
): Observable<ProductSearchPage>;

abstract searchByCodes(
codes: string[],
scope?: string
): Observable<{ products: Product[] }>;

abstract loadSuggestions(
term: string,
pageSize?: number
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ class MockProductSearchAdapter implements ProductSearchAdapter {
loadSuggestions = createSpy(
'ProductSearchAdapter.loadSuggestions'
).and.callFake((term) => of('term:' + term));

searchByCodes = createSpy('ProductSearchAdapter.searchByCodes').and.callFake(
(codes, scope) => of({ products: codes.map((code) => ({ code, scope })) })
);
}

describe('ProductSearchConnector', () => {
Expand All @@ -36,7 +40,28 @@ describe('ProductSearchConnector', () => {
let result;
service.search('test query').subscribe((res) => (result = res));
expect(result).toBe('search:test query');
expect(adapter.search).toHaveBeenCalledWith('test query', undefined);
expect(adapter.search).toHaveBeenCalledWith(
'test query',
undefined,
undefined
);
});

it('searchByCodes should call adapter', () => {
let result;
service
.searchByCodes(['code1', 'code2'])
.subscribe((res) => (result = res));
expect(result).toEqual({
products: [
{ code: 'code1', scope: undefined },
{ code: 'code2', scope: undefined },
],
});
expect(adapter.searchByCodes).toHaveBeenCalledWith(
['code1', 'code2'],
undefined
);
});

it('getSuggestions should call adapter', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Suggestion,
ProductSearchPage,
} from '../../../model/product-search.model';
import { Product } from '../../../model';

@Injectable({
providedIn: 'root',
Expand All @@ -21,9 +22,17 @@ export class ProductSearchConnector {

search(
query: string,
searchConfig?: SearchConfig
searchConfig?: SearchConfig,
scope?: string
): Observable<ProductSearchPage> {
return this.adapter.search(query, searchConfig);
return this.adapter.search(query, searchConfig, scope);
}

searchByCodes(
codes: string[],
scope?: string
): Observable<{ products: Product[] }> {
return this.adapter.searchByCodes(codes, scope);
}

getSuggestions(term: string, pageSize?: number): Observable<Suggestion[]> {
Expand Down
1 change: 1 addition & 0 deletions projects/core/src/product/facade/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
export * from './product-reference.service';
export * from './product-review.service';
export * from './product-search.service';
export * from './product-search-by-code.service';
export * from './product.service';
export * from './searchbox.service';
Loading

0 comments on commit 31c3de9

Please sign in to comment.