diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/ssr/product-listing-page.core-e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/ssr/product-listing-page.core-e2e.cy.ts new file mode 100644 index 00000000000..295e1211259 --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/ssr/product-listing-page.core-e2e.cy.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team <spartacus-team@sap.com> + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SSR_E2E_PLP_SCENARIOS } from '../../helpers/ssr/product-listing-page'; + +const SEARCH_REQUEST_URL = '**/products/search?**'; +const scenarios = SSR_E2E_PLP_SCENARIOS; + +describe('SSR - Product Listing Page', () => { + /** + * This tests all the scenarios where SSR should render the page but the search request is + * only made on the initial page load. The server should have rendered the page and return + * the cached page that does NOT need to request the search api again on reload. + * + * Note: When in development, restarting the dev ssr server (npm run dev:ssr) may be required + * to clear the rendering cache. + */ + describe('search request should only be made once and NOT on page reload', () => { + for (let scenario of scenarios) { + // Skip is used in case of going back to a page that would be already cached + // since another search request would NOT be made. + if (!scenario.skipReloadTest) { + it(scenario.case, () => { + cy.intercept(SEARCH_REQUEST_URL).as('search-init'); + cy.visit(scenario.url); + cy.wait('@search-init'); + + cy.intercept(SEARCH_REQUEST_URL, cy.spy().as('search-2nd')); + cy.reload(); + cy.get('cx-product-list'); + cy.get('@search-2nd').should('not.have.been.called'); + }); + } + } + }); + + /** + * This tests that navigation has not broken when navigating options such as paginations and sorts. + */ + describe( + 'should be able to navigate through all scenarios and trigger requests for each case', + { testIsolation: false }, + () => { + for (let i = 0; i < scenarios.length; i++) { + const scenario = scenarios[i]; + const previous = scenarios[i - 1]; + it(scenario.case, () => { + // Visit whenever no next step from previous scenario to begin with new search type. + if (!previous?.navigateToNext) { + cy.visit(scenarios[i].url); + } + + cy.get('cx-product-list'); + cy.url().should('contain', scenario.url); + + // Make sure navigation has happened successfully by checking a search request was made. + if (scenario.navigateToNext) { + cy.intercept(SEARCH_REQUEST_URL).as('search'); + scenario.navigateToNext(); + cy.wait('@search'); + } + }); + } + } + ); +}); diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/ssr/product-listing-page.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/ssr/product-listing-page.ts new file mode 100644 index 00000000000..d01a9381e42 --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/ssr/product-listing-page.ts @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team <spartacus-team@sap.com> + * + * SPDX-License-Identifier: Apache-2.0 + */ + +const CASE_TITLES = { + ALL_BRANDS: 'All Brands', + QUERY_GRIP: 'Search Query "grip"', + DIGITAL_CAMERAS: 'Digital Cameras', +}; + +const CASE_URLS = { + ALL_BRANDS: '/Brands/all/c/brands', + QUERY_GRIP: '/search/grip', + DIGITAL_CAMERAS: '/Open-Catalogue/Cameras/Digital-Cameras/c/575', +}; + +const CASE_QUERY_PARTS = { + ALL_BRANDS: '?query=:topRated:allCategories:brands', + QUERY_GRIP: '?query=grip:topRated', + DIGITAL_CAMERAS: '?query=:topRated:allCategories:575', +}; + +function getStandardCases(key: string) { + return [ + { + case: CASE_TITLES[key], + url: CASE_URLS[key], + navigateToNext: () => { + cy.get('cx-pagination a.page[aria-label="page 2"]').first().click(); + }, + }, + { + case: CASE_TITLES[key] + ' (2nd page)', + url: CASE_URLS[key] + '?currentPage=1', + navigateToNext: () => { + cy.get('cx-pagination a.start[aria-label="first page"]') + .first() + .click(); + }, + }, + { + case: CASE_TITLES[key] + ' (back to first page)', + url: CASE_URLS[key], + navigateToNext: () => { + cy.get('cx-sorting .ng-select').first().ngSelect('Top Rated'); + }, + skipReloadTest: true, + }, + { + case: CASE_TITLES[key] + ' (with sort)', + url: CASE_URLS[key] + '?sortCode=topRated', + navigateToNext: () => { + cy.get('cx-pagination a.page[aria-label="page 2"]').first().click(); + }, + }, + { + case: CASE_TITLES[key] + ' (2nd page with sort)', + url: CASE_URLS[key] + '?sortCode=topRated¤tPage=1', + navigateToNext: () => { + cy.get('cx-facet a').contains('Chiba').click(); + }, + }, + { + case: CASE_TITLES[key] + ' (with query and sort)', + url: CASE_URLS[key] + CASE_QUERY_PARTS[key] + ':availableInStores:Chiba', + navigateToNext: () => { + cy.get('cx-pagination a.page[aria-label="page 2"]').first().click(); + }, + }, + { + case: CASE_TITLES[key] + ' (2nd page with query and sort)', + url: + CASE_URLS[key] + + CASE_QUERY_PARTS[key] + + ':availableInStores:Chiba¤tPage=1', + navigateToNext: () => { + cy.get('cx-sorting .ng-select').first().ngSelect('Relevance'); + }, + }, + { + case: CASE_TITLES[key] + ' (with query changing sort to default)', + url: + CASE_URLS[key] + + CASE_QUERY_PARTS[key] + + ':availableInStores:Chiba¤tPage=1&sortCode=relevance', + }, + ]; +} + +export const SSR_E2E_PLP_SCENARIOS = [ + ...getStandardCases('ALL_BRANDS'), + ...getStandardCases('QUERY_GRIP'), + ...getStandardCases('DIGITAL_CAMERAS'), +]; diff --git a/projects/storefrontapp-e2e-cypress/package.json b/projects/storefrontapp-e2e-cypress/package.json index d99fb6c0a3c..220930939d5 100644 --- a/projects/storefrontapp-e2e-cypress/package.json +++ b/projects/storefrontapp-e2e-cypress/package.json @@ -25,7 +25,7 @@ "cy:run:ci:ccv2-b2b": "cypress run --config-file cypress.config.ci.ts --config baseUrl=$ENDPOINT_URL_PUBLIC_SPARTACUS --reporter junit --reporter-options mochaFile=results/spartacus-test-results-[hash].xml --env API_URL=$ENDPOINT_URL_PUBLIC_API,BASE_SITE=powertools-spa,OCC_PREFIX_USER_ENDPOINT=orgUsers --spec \"cypress/e2e/cx_ccv2/regression/b2b/**/*.e2e.cy.ts\"", "cy:run:ci:ccv2-product-configurator": "cypress run --config-file cypress.config.ci.ts --config baseUrl=$ENDPOINT_URL_PUBLIC_SPARTACUS --reporter junit --reporter-options mochaFile=results/spartacus-test-results-[hash].xml --env API_URL=$ENDPOINT_URL_PUBLIC_API,BASE_SITE=$E2E_BASE_SITE,OCC_PREFIX_USER_ENDPOINT=orgUsers --spec $E2ES_TO_RUN", "cy:run:ci:mcs": "cypress run --config-file cypress.config.ci.ts --config baseUrl=$ENDPOINT_URL_PUBLIC_SPARTACUS --reporter junit --reporter-options mochaFile=results/spartacus-test-results-[hash].xml --env API_URL=$ENDPOINT_URL_PUBLIC_API --spec \"cypress/e2e/cx_mcs/regression/b2c/**/*e2e.cy.ts\"", - "cy:run:ci:ssr": "cypress run --config-file cypress.config.ci.ts --config baseUrl=http://localhost:4000 --record --key $CYPRESS_KEY --tag \"ssr,all\" --parallel --ci-build-id $BUILD_NUMBER --group SSR --spec \"cypress/e2e/ssr/pages.core-e2e.cy.ts\" --reporter junit --reporter-options mochaFile=results/e2e-test-ssr-[hash].xml", + "cy:run:ci:ssr": "cypress run --config-file cypress.config.ci.ts --config baseUrl=http://localhost:4000 --record --key $CYPRESS_KEY --tag \"ssr,all\" --parallel --ci-build-id $BUILD_NUMBER --group SSR --spec \"cypress/e2e/ssr/*.core-e2e.cy.ts\" --reporter junit --reporter-options mochaFile=results/e2e-test-ssr-[hash].xml", "cy:run:ci:cds": "cypress run --config-file cypress.config.ci.ts --record --key $CYPRESS_KEY --tag \"2011,b2c,all-cds\" --group CDS --spec \"cypress/e2e/vendor/cds/**/*.core-e2e.cy.ts\"", "cy:run:ci:cdc": "cypress run --config-file cypress.config.ci.ts --env API_URL=https://api.cg79x9wuu9-eccommerc1-s1-public.model-t.myhybris.cloud/ --record --key $CYPRESS_KEY --tag \"2005,cdc\" --group CDC --spec \"cypress/e2e/vendor/cdc/b2c/*.e2e.cy.ts\"", "cy:run:ci:cdc-b2b": "cypress run --config-file cypress.ci.json --env BASE_SITE=powertools-spa,OCC_PREFIX_USER_ENDPOINT=orgUsers,API_URL=https://api.cg79x9wuu9-eccommerc1-s1-public.model-t.myhybris.cloud/ --record --key $CYPRESS_KEY --tag \"2211,cdc\" --group CDC --spec \"cypress/integration/vendor/cdc/b2b/*.e2e.cy.ts\"", diff --git a/projects/storefrontlib/cms-components/product/product-list/container/product-list-component.service.spec.ts b/projects/storefrontlib/cms-components/product/product-list/container/product-list-component.service.spec.ts index a8679cbf29d..127ac3ed0b8 100644 --- a/projects/storefrontlib/cms-components/product/product-list/container/product-list-component.service.spec.ts +++ b/projects/storefrontlib/cms-components/product/product-list/container/product-list-component.service.spec.ts @@ -3,6 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRouterStateSnapshot, CurrencyService, + FeatureConfigService, LanguageService, ProductSearchPage, ProductSearchService, @@ -52,6 +53,12 @@ class MockLanguageService { } } +class MockFeatureConfigService implements Partial<FeatureConfigService> { + isLevel(): boolean { + return true; + } +} + describe('ProductListComponentService', () => { let service: ProductListComponentService; let activatedRoute: ActivatedRoute; @@ -82,6 +89,7 @@ describe('ProductListComponentService', () => { { provide: ProductSearchService, useClass: MockProductSearchService }, { provide: CurrencyService, useClass: MockCurrencyService }, { provide: LanguageService, useClass: MockLanguageService }, + { provide: FeatureConfigService, useClass: MockFeatureConfigService }, provideDefaultConfig(<ViewConfig>defaultViewConfig), ], }); @@ -290,5 +298,215 @@ describe('ProductListComponentService', () => { ); })); }); + + describe('should perform search ONLY if product data does not already exist (state transfered by SSR)', () => { + it('by default', fakeAsync(() => { + mockRoutingState({}); + productSearchService.getResults = () => + of({ + pagination: { + pageSize: 12, + }, + }); + + const subscription: Subscription = service.model$.subscribe(); + + tick(); + + subscription.unsubscribe(); + + expect(productSearchService.search).not.toHaveBeenCalled(); + })); + + it('param "categoryCode"', fakeAsync(() => { + mockRoutingState({ + params: { categoryCode: 'testCategory' }, + }); + productSearchService.getResults = () => + of({ + currentQuery: { + query: { value: 'relevance:allCategories:testCategory' }, + }, + pagination: { + pageSize: 12, + }, + }); + + const subscription: Subscription = service.model$.subscribe(); + + tick(); + + subscription.unsubscribe(); + + expect(productSearchService.search).not.toHaveBeenCalled(); + })); + + it('param "brandCode"', fakeAsync(() => { + mockRoutingState({ + params: { brandCode: 'testBrand' }, + }); + productSearchService.getResults = () => + of({ + currentQuery: { + query: { value: 'relevance:allCategories:testBrand' }, + }, + pagination: { + pageSize: 12, + }, + }); + + const subscription: Subscription = service.model$.subscribe(); + + tick(); + + subscription.unsubscribe(); + + expect(productSearchService.search).not.toHaveBeenCalled(); + })); + + it('param "query"', fakeAsync(() => { + mockRoutingState({ + params: { query: 'testQuery' }, + }); + productSearchService.getResults = () => + of({ + currentQuery: { + query: { value: 'testQuery' }, + }, + pagination: { + pageSize: 12, + }, + }); + + const subscription: Subscription = service.model$.subscribe(); + + tick(); + + subscription.unsubscribe(); + + expect(productSearchService.search).not.toHaveBeenCalled(); + })); + + it('query param "query"', fakeAsync(() => { + mockRoutingState({ + queryParams: { query: 'testQuery' }, + }); + productSearchService.getResults = () => + of({ + currentQuery: { + query: { value: 'testQuery' }, + }, + pagination: { + pageSize: 12, + }, + }); + + const subscription: Subscription = service.model$.subscribe(); + + tick(); + + subscription.unsubscribe(); + + expect(productSearchService.search).not.toHaveBeenCalled(); + })); + + it('param "query" and query param "query"', fakeAsync(() => { + mockRoutingState({ + params: { query: 'testQuery1' }, + queryParams: { query: 'testQuery2' }, + }); + productSearchService.getResults = () => + of({ + currentQuery: { + query: { value: 'testQuery2' }, + }, + pagination: { + pageSize: 12, + }, + }); + + const subscription: Subscription = service.model$.subscribe(); + + tick(); + + subscription.unsubscribe(); + + expect(productSearchService.search).not.toHaveBeenCalled(); + })); + + it('query param "currentPage"', fakeAsync(() => { + mockRoutingState({ + params: { query: 'testQuery' }, + queryParams: { currentPage: 123 }, + }); + productSearchService.getResults = () => + of({ + currentQuery: { + query: { value: 'testQuery' }, + }, + pagination: { + pageSize: 12, + currentPage: 123, + }, + }); + + const subscription: Subscription = service.model$.subscribe(); + + tick(); + + subscription.unsubscribe(); + + expect(productSearchService.search).not.toHaveBeenCalled(); + })); + + it('query param "pageSize"', fakeAsync(() => { + mockRoutingState({ + params: { query: 'testQuery' }, + queryParams: { pageSize: 20 }, + }); + productSearchService.getResults = () => + of({ + currentQuery: { + query: { value: 'testQuery' }, + }, + pagination: { + pageSize: 20, + }, + }); + + const subscription: Subscription = service.model$.subscribe(); + + tick(); + + subscription.unsubscribe(); + + expect(productSearchService.search).not.toHaveBeenCalled(); + })); + + it('query param "sortCode"', fakeAsync(() => { + mockRoutingState({ + params: { query: 'testQuery' }, + queryParams: { sortCode: 'name-asc' }, + }); + productSearchService.getResults = () => + of({ + currentQuery: { + query: { value: 'testQuery' }, + }, + pagination: { + pageSize: 12, + sort: 'name-asc', + }, + }); + + const subscription: Subscription = service.model$.subscribe(); + + tick(); + + subscription.unsubscribe(); + + expect(productSearchService.search).not.toHaveBeenCalled(); + })); + }); }); }); diff --git a/projects/storefrontlib/cms-components/product/product-list/container/product-list-component.service.ts b/projects/storefrontlib/cms-components/product/product-list/container/product-list-component.service.ts index 8a19331d6f2..547f8db65bf 100644 --- a/projects/storefrontlib/cms-components/product/product-list/container/product-list-component.service.ts +++ b/projects/storefrontlib/cms-components/product/product-list/container/product-list-component.service.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRouterStateSnapshot, CurrencyService, + FeatureConfigService, LanguageService, ProductSearchPage, ProductSearchService, @@ -22,6 +23,7 @@ import { filter, map, shareReplay, + take, tap, } from 'rxjs/operators'; import { ViewConfig } from '../../../../shared/config/view-config'; @@ -39,6 +41,11 @@ import { ProductListRouteParams, SearchCriteria } from './product-list.model'; export class ProductListComponentService { protected readonly RELEVANCE_ALLCATEGORIES = ':relevance:allCategories:'; + // TODO: Remove in 7.0 + protected featureConfigService = inject(FeatureConfigService, { + optional: true, + }); + constructor( protected productSearchService: ProductSearchService, protected routing: RoutingService, @@ -84,10 +91,95 @@ export class ProductListComponentService { state.params, state.queryParams ); - this.search(criteria); + + // TODO: Remove featureLevel condition in 7.0 + if (this.featureConfigService?.isLevel('6.7')) { + this.searchIfCriteriaHasChanged(criteria); + } else { + this.search(criteria); + } }) ); + /** + * Search only if the previous search criteria does NOT match the new one. + * This prevents repeating product search calls for queries that already have loaded data. + */ + protected searchIfCriteriaHasChanged(criteria: SearchCriteria) { + this.productSearchService + .getResults() + .pipe(take(1)) + .subscribe((results) => { + const previous: SearchCriteria = { + query: results?.currentQuery?.query?.value, + currentPage: results?.pagination?.currentPage, + pageSize: results?.pagination?.pageSize, + sortCode: results?.pagination?.sort, + }; + + if ( + checkQueriesDiffer() || + checkCurrentPagesDiffer() || + checkPageSizesDiffer() || + checkSortCodesDiffer() + ) { + this.search(criteria); + } + + function checkQueriesDiffer(): boolean { + const previousQuery = sanitizeQuery( + previous.query, + previous.sortCode + ); + const currentQuery = sanitizeQuery(criteria.query, criteria.sortCode); + return previousQuery !== currentQuery; + + // Remove sortCode portion from queries. + function sanitizeQuery( + query?: string, + sortCode?: string + ): string | undefined { + const DEFAULT_SORT_CODE = 'relevance'; + + query = query + ?.replace(':' + DEFAULT_SORT_CODE, '') + .replace(DEFAULT_SORT_CODE, ''); + + if (sortCode) { + query = query?.replace(':' + sortCode, '').replace(sortCode, ''); + } + + return query; + } + } + + function checkCurrentPagesDiffer() { + // Can be stored as zero for previousCriteria but undefined as new criteria. + // We need to set these to the zero-values to perform the equivalency check. + const previousPage = + previous.currentPage && previous.currentPage > 0 + ? previous.currentPage + : undefined; + return previousPage?.toString() !== criteria.currentPage?.toString(); + } + + function checkPageSizesDiffer() { + return ( + previous.pageSize?.toString() !== criteria.pageSize?.toString() + ); + } + + function checkSortCodesDiffer() { + // Only check "sortCode" if it is defined in criteria as sortCode is often an undefined queryParam + // but it will always get defined as a string in previousCriteria if a search was made. + const previousCode = criteria.sortCode + ? previous?.sortCode + : undefined; + return previousCode?.toString() !== criteria.sortCode?.toString(); + } + }); + } + /** * This stream is used for the Product Listing and Product Facets. *