diff --git a/packages/headless-react/src/__tests__/mock-products.ts b/packages/headless-react/src/__tests__/mock-products.ts index b5c0d0056a2..4745d476acd 100644 --- a/packages/headless-react/src/__tests__/mock-products.ts +++ b/packages/headless-react/src/__tests__/mock-products.ts @@ -1,5 +1,6 @@ import {randomUUID} from 'node:crypto'; import type {Product} from '@coveo/headless/ssr-commerce'; +import {ResultType} from '@coveo/headless/ssr-commerce'; const createMockProduct = (overrides: Partial = {}): Product => ({ additionalFields: {}, @@ -24,6 +25,7 @@ const createMockProduct = (overrides: Partial = {}): Product => ({ permanentid: randomUUID(), position: 1, totalNumberOfChildren: 0, + resultType: ResultType.PRODUCT, ...overrides, }); diff --git a/packages/headless/src/api/commerce/commerce-api-client.test.ts b/packages/headless/src/api/commerce/commerce-api-client.test.ts index 90ab2e50190..920b33e5da2 100644 --- a/packages/headless/src/api/commerce/commerce-api-client.test.ts +++ b/packages/headless/src/api/commerce/commerce-api-client.test.ts @@ -9,6 +9,7 @@ import { } from './commerce-api-client.js'; import type {FilterableCommerceAPIRequest} from './common/request.js'; import type {CommerceResponse} from './common/response.js'; +import type {CommerceListingRequest} from './listing/request.js'; import type {CommerceRecommendationsRequest} from './recommendations/recommendations-request.js'; describe('commerce api client', () => { @@ -81,7 +82,10 @@ describe('commerce api client', () => { }; it('#getProductListing should call the platform endpoint with the correct arguments', async () => { - const request = await buildCommerceAPIRequest(); + const request: CommerceListingRequest = { + ...(await buildCommerceAPIRequest()), + enableResults: false, + }; mockPlatformCall({ ok: true, @@ -279,7 +283,10 @@ describe('commerce api client', () => { }); it('should return error response on failure', async () => { - const request = await buildCommerceAPIRequest(); + const request: CommerceListingRequest = { + ...(await buildCommerceAPIRequest()), + enableResults: false, + }; const expectedError = { statusCode: 401, @@ -300,7 +307,10 @@ describe('commerce api client', () => { }); it('should return success response on success', async () => { - const request = await buildCommerceAPIRequest(); + const request: CommerceListingRequest = { + ...(await buildCommerceAPIRequest()), + enableResults: false, + }; const expectedBody: CommerceResponse = { products: [], diff --git a/packages/headless/src/api/commerce/commerce-api-client.ts b/packages/headless/src/api/commerce/commerce-api-client.ts index 7b37b1a89bf..a77c520f539 100644 --- a/packages/headless/src/api/commerce/commerce-api-client.ts +++ b/packages/headless/src/api/commerce/commerce-api-client.ts @@ -14,15 +14,14 @@ import type { CommerceAPIErrorResponse, CommerceAPIErrorStatusResponse, } from './commerce-api-error-response.js'; -import { - type FilterableCommerceAPIRequest, - getRequestOptions, -} from './common/request.js'; +import {getRequestOptions} from './common/request.js'; import type {CommerceSuccessResponse} from './common/response.js'; import type { CommerceFacetSearchRequest, FacetSearchType, } from './facet-search/facet-search-request.js'; +import type {CommerceListingRequest} from './listing/request.js'; +import type {ListingCommerceSuccessResponse} from './listing/response.js'; import { buildRecommendationsRequest, type CommerceRecommendationsRequest, @@ -74,10 +73,15 @@ export class CommerceAPIClient implements CommerceFacetSearchAPIClient { constructor(private options: CommerceAPIClientOptions) {} async getProductListing( - req: FilterableCommerceAPIRequest - ): Promise> { + req: CommerceListingRequest + ): Promise> { + const requestOptions = getRequestOptions(req, 'listing'); return this.query({ - ...getRequestOptions(req, 'listing'), + ...requestOptions, + requestParams: { + ...requestOptions.requestParams, + enableResults: req.enableResults, + }, ...this.options, }); } diff --git a/packages/headless/src/api/commerce/commerce-api-params.ts b/packages/headless/src/api/commerce/commerce-api-params.ts index 47cbe639d5a..1add82624f4 100644 --- a/packages/headless/src/api/commerce/commerce-api-params.ts +++ b/packages/headless/src/api/commerce/commerce-api-params.ts @@ -25,6 +25,10 @@ export interface ContextParam { context: ContextParams; } +export interface EnableResultsParam { + enableResults: boolean; +} + type ProductParam = { productId: string; }; diff --git a/packages/headless/src/api/commerce/common/product.ts b/packages/headless/src/api/commerce/common/product.ts index 89f5620c6e2..833e3a675af 100644 --- a/packages/headless/src/api/commerce/common/product.ts +++ b/packages/headless/src/api/commerce/common/product.ts @@ -1,4 +1,5 @@ import type {HighlightKeyword} from '../../../utils/highlight.js'; +import type {ResultPosition, ResultType} from './result.js'; export type ChildProduct = Omit< BaseProduct, @@ -143,13 +144,10 @@ export interface BaseProduct { * The ID of the response that returned the product. */ responseId?: string; -} - -export interface Product extends BaseProduct { /** - * The 1-based product's position across the non-paginated result set. - * - * For example, if the product is the third one on the second page, and there are 10 products per page, its position is 13 (not 3). + * The result type of the product. */ - position: number; + resultType: ResultType.PRODUCT | ResultType.CHILD_PRODUCT; } + +export interface Product extends ResultPosition, BaseProduct {} diff --git a/packages/headless/src/api/commerce/common/result.ts b/packages/headless/src/api/commerce/common/result.ts new file mode 100644 index 00000000000..714951ca549 --- /dev/null +++ b/packages/headless/src/api/commerce/common/result.ts @@ -0,0 +1,58 @@ +import type {BaseProduct, Product} from './product.js'; + +export enum ResultType { + CHILD_PRODUCT = 'childProduct', + PRODUCT = 'product', + SPOTLIGHT = 'spotlight', +} + +export interface BaseSpotlightContent { + /** + * The unique identifier of the spotlight content. + */ + id: string; + /** + * The URI to navigate to when the spotlight content is clicked. + */ + clickUri: string; + /** + * The image URL for desktop display. + */ + desktopImage: string; + /** + * The image URL for mobile display. + */ + mobileImage?: string; + /** + * The name of the spotlight content. + */ + name?: string; + /** + * The description of the spotlight content. + */ + description?: string; + /** + * The ID of the response that returned the spotlight content. + */ + responseId?: string; + /** + * The result type identifier, always SPOTLIGHT for spotlight content. + */ + resultType: ResultType.SPOTLIGHT; +} + +export interface ResultPosition { + /** + * The 1-based result's position across the non-paginated result set. + * + * For example, if the result is the third one on the second page, and there are 10 results per page, its position is 13 (not 3). + */ + position: number; +} + +export interface SpotlightContent + extends ResultPosition, + BaseSpotlightContent {} + +export type BaseResult = BaseProduct | BaseSpotlightContent; +export type Result = Product | SpotlightContent; diff --git a/packages/headless/src/api/commerce/listing/request.ts b/packages/headless/src/api/commerce/listing/request.ts new file mode 100644 index 00000000000..74de212da8c --- /dev/null +++ b/packages/headless/src/api/commerce/listing/request.ts @@ -0,0 +1,5 @@ +import type {EnableResultsParam} from '../commerce-api-params.js'; +import type {FilterableCommerceAPIRequest} from '../common/request.js'; + +export type CommerceListingRequest = FilterableCommerceAPIRequest & + EnableResultsParam; diff --git a/packages/headless/src/api/commerce/listing/response.ts b/packages/headless/src/api/commerce/listing/response.ts new file mode 100644 index 00000000000..ff086efac76 --- /dev/null +++ b/packages/headless/src/api/commerce/listing/response.ts @@ -0,0 +1,7 @@ +import type {CommerceSuccessResponse} from '../common/response.js'; +import type {BaseResult} from '../common/result.js'; + +export interface ListingCommerceSuccessResponse + extends CommerceSuccessResponse { + results: BaseResult[]; +} diff --git a/packages/headless/src/commerce.index.ts b/packages/headless/src/commerce.index.ts index 382ae555d1b..fa83e4872d2 100644 --- a/packages/headless/src/commerce.index.ts +++ b/packages/headless/src/commerce.index.ts @@ -239,6 +239,7 @@ export type { export {buildInstantProducts} from './controllers/commerce/instant-products/headless-instant-products.js'; export type { ProductListing, + ProductListingOptions, ProductListingState, } from './controllers/commerce/product-listing/headless-product-listing.js'; export {buildProductListing} from './controllers/commerce/product-listing/headless-product-listing.js'; diff --git a/packages/headless/src/controllers/commerce/product-listing/headless-product-listing.test.ts b/packages/headless/src/controllers/commerce/product-listing/headless-product-listing.test.ts index ee4ed749e6a..b40300f6f1e 100644 --- a/packages/headless/src/controllers/commerce/product-listing/headless-product-listing.test.ts +++ b/packages/headless/src/controllers/commerce/product-listing/headless-product-listing.test.ts @@ -29,18 +29,13 @@ import { facetResponseSelector, isFacetLoadingResponseSelector, } from './facets/headless-product-listing-facet-options.js'; -import { - buildProductListing, - type ProductListing, -} from './headless-product-listing.js'; +import {buildProductListing} from './headless-product-listing.js'; describe('headless product-listing', () => { - let productListing: ProductListing; let engine: MockedCommerceEngine; beforeEach(() => { engine = buildMockCommerceEngine(buildMockCommerceState()); - productListing = buildProductListing(engine); }); afterEach(() => { @@ -57,8 +52,8 @@ describe('headless product-listing', () => { expect(buildProductListingSubControllers).toHaveBeenCalledWith(engine, { responseIdSelector, - fetchProductsActionCreator: ProductListingActions.fetchProductListing, - fetchMoreProductsActionCreator: ProductListingActions.fetchMoreProducts, + fetchProductsActionCreator: expect.any(Function), + fetchMoreProductsActionCreator: expect.any(Function), facetResponseSelector, isFacetLoadingResponseSelector, requestIdSelector, @@ -75,7 +70,58 @@ describe('headless product-listing', () => { }); }); + it('creates closures for fetching products that capture default enableResults=false', () => { + const buildProductListingSubControllers = vi.spyOn( + SubControllers, + 'buildProductListingSubControllers' + ); + const fetchProductListingMock = vi.spyOn( + ProductListingActions, + 'fetchProductListing' + ); + const fetchMoreProductsMock = vi.spyOn( + ProductListingActions, + 'fetchMoreProducts' + ); + + buildProductListing(engine); + + const callArgs = buildProductListingSubControllers.mock.calls[0][1]; + callArgs.fetchProductsActionCreator(); + expect(fetchProductListingMock).toHaveBeenCalledWith({ + enableResults: false, + }); + + callArgs.fetchMoreProductsActionCreator(); + expect(fetchMoreProductsMock).toHaveBeenCalledWith({enableResults: false}); + }); + + it('creates closures for fetching products that capture enableResults=true', () => { + const buildProductListingSubControllers = vi.spyOn( + SubControllers, + 'buildProductListingSubControllers' + ); + const fetchProductListingMock = vi.spyOn( + ProductListingActions, + 'fetchProductListing' + ); + const fetchMoreProductsMock = vi.spyOn( + ProductListingActions, + 'fetchMoreProducts' + ); + + buildProductListing(engine, {enableResults: true}); + + const callArgs = buildProductListingSubControllers.mock.calls[0][1]; + callArgs.fetchProductsActionCreator(); + expect(fetchProductListingMock).toHaveBeenCalledWith({enableResults: true}); + + callArgs.fetchMoreProductsActionCreator(); + expect(fetchMoreProductsMock).toHaveBeenCalledWith({enableResults: true}); + }); + it('adds the correct reducers to engine', () => { + buildProductListing(engine); expect(engine.addReducers).toHaveBeenCalledWith({ productListing: productListingReducer, commerceContext: contextReducer, @@ -90,6 +136,7 @@ describe('headless product-listing', () => { ); const child = {permanentid: 'childPermanentId'} as ChildProduct; + const productListing = buildProductListing(engine); productListing.promoteChildToParent(child); expect(promoteChildToParent).toHaveBeenCalledWith({ @@ -97,25 +144,54 @@ describe('headless product-listing', () => { }); }); - it('#refresh dispatches #fetchProductListing', () => { + it('#refresh dispatches #fetchProductListing with enableResults=false by default', () => { const fetchProductListing = vi.spyOn( ProductListingActions, 'fetchProductListing' ); + const productListing = buildProductListing(engine); productListing.refresh(); - expect(fetchProductListing).toHaveBeenCalled(); + expect(fetchProductListing).toHaveBeenCalledWith({enableResults: false}); }); - it('#executeFirstRequest dispatches #fetchProductListing', () => { - const executeRequest = vi.spyOn( + it('#refresh dispatches #fetchProductListing with enableResults=true when specified', () => { + const fetchProductListing = vi.spyOn( ProductListingActions, 'fetchProductListing' ); + const productListingWithResults = buildProductListing(engine, { + enableResults: true, + }); + + productListingWithResults.refresh(); + expect(fetchProductListing).toHaveBeenCalledWith({enableResults: true}); + }); + + it('#executeFirstRequest dispatches #fetchProductListing with enableResults=false by default', () => { + const executeRequest = vi.spyOn( + ProductListingActions, + 'fetchProductListing' + ); + const productListing = buildProductListing(engine); productListing.executeFirstRequest(); - expect(executeRequest).toHaveBeenCalled(); + expect(executeRequest).toHaveBeenCalledWith({enableResults: false}); + }); + + it('#executeFirstRequest dispatches #fetchProductListing with enableResults=true when specified', () => { + const executeRequest = vi.spyOn( + ProductListingActions, + 'fetchProductListing' + ); + const productListingWithResults = buildProductListing(engine, { + enableResults: true, + }); + + productListingWithResults.executeFirstRequest(); + + expect(executeRequest).toHaveBeenCalledWith({enableResults: true}); }); }); diff --git a/packages/headless/src/controllers/commerce/product-listing/headless-product-listing.ts b/packages/headless/src/controllers/commerce/product-listing/headless-product-listing.ts index b437d10f24c..84ec67b2bca 100644 --- a/packages/headless/src/controllers/commerce/product-listing/headless-product-listing.ts +++ b/packages/headless/src/controllers/commerce/product-listing/headless-product-listing.ts @@ -3,6 +3,7 @@ import type { ChildProduct, Product, } from '../../../api/commerce/common/product.js'; +import type {Result} from '../../../api/commerce/common/result.js'; import type {CommerceEngine} from '../../../app/commerce-engine/commerce-engine.js'; import {configuration} from '../../../app/common-reducers.js'; import {stateKey} from '../../../app/state-key.js'; @@ -16,6 +17,7 @@ import type {Parameters} from '../../../features/commerce/parameters/parameters- import {parametersDefinition} from '../../../features/commerce/parameters/parameters-schema.js'; import {activeParametersSelector} from '../../../features/commerce/parameters/parameters-selectors.js'; import {productListingSerializer} from '../../../features/commerce/parameters/parameters-serializer.js'; +import type {FetchProductListingPayload} from '../../../features/commerce/product-listing/product-listing-actions.js'; import { fetchMoreProducts, fetchProductListing, @@ -97,21 +99,35 @@ export interface ProductListing */ export interface ProductListingState { products: Product[]; + results: Result[]; error: CommerceAPIErrorStatusResponse | null; isLoading: boolean; responseId: string; } +/** + * Options for configuring the `ProductListing` controller. + * @group Buildable controllers + * @category ProductListing + */ +export interface ProductListingOptions extends FetchProductListingPayload {} + /** * Creates a `ProductListing` controller instance. * * @param engine - The headless commerce engine. + * @param options - The configurable `ProductListing` controller options. * @returns A `ProductListing` controller instance. * * @group Buildable controllers * @category ProductListing */ -export function buildProductListing(engine: CommerceEngine): ProductListing { +export function buildProductListing( + engine: CommerceEngine, + {enableResults = false}: ProductListingOptions = { + enableResults: false, + } +): ProductListing { if (!loadBaseProductListingReducers(engine)) { throw loadReducerError; } @@ -119,10 +135,11 @@ export function buildProductListing(engine: CommerceEngine): ProductListing { const controller = buildController(engine); const {dispatch} = engine; const getState = () => engine[stateKey]; + const subControllers = buildProductListingSubControllers(engine, { responseIdSelector, - fetchProductsActionCreator: fetchProductListing, - fetchMoreProductsActionCreator: fetchMoreProducts, + fetchProductsActionCreator: () => fetchProductListing({enableResults}), + fetchMoreProductsActionCreator: () => fetchMoreProducts({enableResults}), facetResponseSelector, isFacetLoadingResponseSelector, requestIdSelector, @@ -143,10 +160,11 @@ export function buildProductListing(engine: CommerceEngine): ProductListing { ...subControllers, get state() { - const {products, error, isLoading, responseId} = + const {products, results, error, isLoading, responseId} = getState().productListing; return { products, + results, error, isLoading, responseId, @@ -157,7 +175,7 @@ export function buildProductListing(engine: CommerceEngine): ProductListing { dispatch(promoteChildToParent({child})); }, - refresh: () => dispatch(fetchProductListing()), + refresh: () => dispatch(fetchProductListing({enableResults})), executeFirstRequest() { const firstRequestExecuted = responseIdSelector(getState()) !== ''; @@ -166,7 +184,7 @@ export function buildProductListing(engine: CommerceEngine): ProductListing { return; } - dispatch(fetchProductListing()); + dispatch(fetchProductListing({enableResults})); }, }; } diff --git a/packages/headless/src/features/commerce/instant-products/instant-products-slice.ts b/packages/headless/src/features/commerce/instant-products/instant-products-slice.ts index f0f9545bb82..dd08a23c008 100644 --- a/packages/headless/src/features/commerce/instant-products/instant-products-slice.ts +++ b/packages/headless/src/features/commerce/instant-products/instant-products-slice.ts @@ -4,6 +4,7 @@ import type { ChildProduct, Product, } from '../../../api/commerce/common/product.js'; +import {ResultType} from '../../../api/commerce/common/result.js'; import { clearExpiredItems, fetchItemsFulfilled, @@ -91,6 +92,7 @@ export const instantProductsReducer = createReducer( const newParent: Product = { ...(childToPromote as ChildProduct), + resultType: ResultType.PRODUCT, children, totalNumberOfChildren, position, diff --git a/packages/headless/src/features/commerce/product-listing/product-listing-actions-loader.ts b/packages/headless/src/features/commerce/product-listing/product-listing-actions-loader.ts index 392de7575f7..63bab45e450 100644 --- a/packages/headless/src/features/commerce/product-listing/product-listing-actions-loader.ts +++ b/packages/headless/src/features/commerce/product-listing/product-listing-actions-loader.ts @@ -2,13 +2,16 @@ import type {AsyncThunkAction, PayloadAction} from '@reduxjs/toolkit'; import type {AsyncThunkCommerceOptions} from '../../../api/commerce/commerce-api-client.js'; import type {CommerceEngine} from '../../../app/commerce-engine/commerce-engine.js'; import {productListingReducer as productListing} from '../../../features/commerce/product-listing/product-listing-slice.js'; +import type { + FetchProductListingPayload, + PromoteChildToParentPayload, + QueryCommerceAPIThunkReturn, + StateNeededByFetchProductListing, +} from './product-listing-actions.js'; import { fetchMoreProducts, fetchProductListing, - type PromoteChildToParentPayload, promoteChildToParent, - type QueryCommerceAPIThunkReturn, - type StateNeededByFetchProductListing, } from './product-listing-actions.js'; /** @@ -23,9 +26,11 @@ export interface ProductListingActionCreators { * * @returns A dispatchable action. */ - fetchProductListing(): AsyncThunkAction< + fetchProductListing( + payload?: FetchProductListingPayload + ): AsyncThunkAction< QueryCommerceAPIThunkReturn, - void, + FetchProductListingPayload, AsyncThunkCommerceOptions >; @@ -34,9 +39,11 @@ export interface ProductListingActionCreators { * * @returns A dispatchable action. */ - fetchMoreProducts(): AsyncThunkAction< + fetchMoreProducts( + payload?: FetchProductListingPayload + ): AsyncThunkAction< QueryCommerceAPIThunkReturn | null, - void, + FetchProductListingPayload, AsyncThunkCommerceOptions >; diff --git a/packages/headless/src/features/commerce/product-listing/product-listing-actions.ts b/packages/headless/src/features/commerce/product-listing/product-listing-actions.ts index 6fddf61e097..452a3b226e4 100644 --- a/packages/headless/src/features/commerce/product-listing/product-listing-actions.ts +++ b/packages/headless/src/features/commerce/product-listing/product-listing-actions.ts @@ -5,7 +5,7 @@ import { isErrorResponse, } from '../../../api/commerce/commerce-api-client.js'; import type {ChildProduct} from '../../../api/commerce/common/product.js'; -import type {CommerceSuccessResponse} from '../../../api/commerce/common/response.js'; +import type {ListingCommerceSuccessResponse} from '../../../api/commerce/listing/response.js'; import type {ProductListingSection} from '../../../state/state-sections.js'; import {validatePayload} from '../../../utils/validate-payload.js'; import { @@ -20,26 +20,37 @@ import { export interface QueryCommerceAPIThunkReturn { /** The successful response. */ - response: CommerceSuccessResponse; + response: ListingCommerceSuccessResponse; } export type StateNeededByFetchProductListing = StateNeededForFilterableCommerceAPIRequest & ProductListingSection; +export interface FetchProductListingPayload { + /** + * When set to true, fills the `results` field rather than the `products` field + * in the response. It may also include Spotlight Content in the results. + * @default false + */ + enableResults?: boolean; +} + export const fetchProductListing = createAsyncThunk< QueryCommerceAPIThunkReturn, - void, + FetchProductListingPayload, AsyncThunkCommerceOptions >( 'commerce/productListing/fetch', async ( - _action, + payload, {getState, rejectWithValue, extra: {apiClient, navigatorContext}} ) => { const state = getState(); - const fetched = await apiClient.getProductListing( - buildFilterableCommerceAPIRequest(state, navigatorContext) - ); + const request = buildFilterableCommerceAPIRequest(state, navigatorContext); + const fetched = await apiClient.getProductListing({ + ...request, + enableResults: Boolean(payload?.enableResults), + }); if (isErrorResponse(fetched)) { return rejectWithValue(fetched.error); @@ -53,12 +64,12 @@ export const fetchProductListing = createAsyncThunk< export const fetchMoreProducts = createAsyncThunk< QueryCommerceAPIThunkReturn | null, - void, + FetchProductListingPayload, AsyncThunkCommerceOptions >( 'commerce/productListing/fetchMoreProducts', async ( - _action, + payload, {getState, rejectWithValue, extra: {apiClient, navigatorContext}} ) => { const state = getState(); @@ -72,6 +83,7 @@ export const fetchMoreProducts = createAsyncThunk< const fetched = await apiClient.getProductListing({ ...buildFilterableCommerceAPIRequest(state, navigatorContext), + enableResults: Boolean(payload?.enableResults), page: nextPageToRequest, }); diff --git a/packages/headless/src/features/commerce/product-listing/product-listing-selectors.test.ts b/packages/headless/src/features/commerce/product-listing/product-listing-selectors.test.ts index a7167f08c5d..03fb4318d9d 100644 --- a/packages/headless/src/features/commerce/product-listing/product-listing-selectors.test.ts +++ b/packages/headless/src/features/commerce/product-listing/product-listing-selectors.test.ts @@ -1,6 +1,7 @@ import {buildMockCommerceState} from '../../../test/mock-commerce-state.js'; import {buildMockCommerceEngine} from '../../../test/mock-engine-v2.js'; import {buildMockProduct} from '../../../test/mock-product.js'; +import {buildMockSpotlightContent} from '../../../test/mock-spotlight-content.js'; import { errorSelector, isLoadingSelector, @@ -17,6 +18,7 @@ describe('commerce product listing selectors', () => { productListing: { responseId: 'some-response-id', products: [], + results: [], isLoading: false, error: null, facets: [], @@ -31,6 +33,7 @@ describe('commerce product listing selectors', () => { productListing: { responseId: 'some-response-id', products: [], + results: [], isLoading: false, error: null, facets: [], @@ -52,6 +55,7 @@ describe('commerce product listing selectors', () => { productListing: { responseId: 'some-response-id', products: [], + results: [], isLoading: false, error: null, facets: [], @@ -71,6 +75,7 @@ describe('commerce product listing selectors', () => { productListing: { responseId: 'some-response-id', products: [buildMockProduct(), buildMockProduct()], + results: [], isLoading: false, error: null, facets: [], @@ -80,7 +85,27 @@ describe('commerce product listing selectors', () => { expect(numberOfProductsSelector(state)).toEqual(2); }); - it('#numberOfProductsSelector should return 0 when the products are not set', () => { + it('#numberOfProductsSelector should return the number of results when products is empty', () => { + const state = buildMockCommerceState({ + productListing: { + responseId: 'some-response-id', + products: [], + results: [ + buildMockProduct(), + buildMockSpotlightContent(), + buildMockProduct(), + buildMockProduct(), + ], + isLoading: false, + error: null, + facets: [], + requestId: 'some-request-id', + }, + }); + expect(numberOfProductsSelector(state)).toEqual(4); + }); + + it('#numberOfProductsSelector should return 0 when both products and results are empty', () => { const state = buildMockCommerceState(); expect(numberOfProductsSelector(state)).toEqual(0); }); @@ -90,6 +115,7 @@ describe('commerce product listing selectors', () => { productListing: { responseId: 'some-response-id', products: [buildMockProduct(), buildMockProduct()], + results: [], isLoading: false, error: null, facets: [], @@ -108,6 +134,7 @@ describe('commerce product listing selectors', () => { productListing: { responseId: 'some-response-id', products: [buildMockProduct(), buildMockProduct()], + results: [], isLoading: false, error: null, facets: [], @@ -126,6 +153,7 @@ describe('commerce product listing selectors', () => { productListing: { responseId: 'some-response-id', products: [buildMockProduct(), buildMockProduct()], + results: [], isLoading: false, error: null, facets: [], @@ -144,6 +172,7 @@ describe('commerce product listing selectors', () => { productListing: { responseId: 'some-response-id', products: [buildMockProduct(), buildMockProduct()], + results: [], isLoading: false, error: null, facets: [], @@ -158,6 +187,7 @@ describe('commerce product listing selectors', () => { productListing: { responseId: 'some-response-id', products: [], + results: [], isLoading: true, error: null, facets: [], @@ -177,6 +207,7 @@ describe('commerce product listing selectors', () => { productListing: { responseId: 'some-response-id', products: [], + results: [], isLoading: false, error: {message: 'some-error', statusCode: 500, type: 'some-type'}, facets: [], diff --git a/packages/headless/src/features/commerce/product-listing/product-listing-selectors.ts b/packages/headless/src/features/commerce/product-listing/product-listing-selectors.ts index ddaba1da376..5e6a239c244 100644 --- a/packages/headless/src/features/commerce/product-listing/product-listing-selectors.ts +++ b/packages/headless/src/features/commerce/product-listing/product-listing-selectors.ts @@ -26,7 +26,10 @@ export const requestIdSelector = (state: CommerceEngineState) => export const numberOfProductsSelector = ( state: Partial -) => state.productListing?.products.length || 0; +) => + state.productListing?.results.length || + state.productListing?.products.length || + 0; export const moreProductsAvailableSelector = createSelector( (state: Partial) => ({ diff --git a/packages/headless/src/features/commerce/product-listing/product-listing-slice.test.ts b/packages/headless/src/features/commerce/product-listing/product-listing-slice.test.ts index a8bbd5eae1d..9082e02dc8e 100644 --- a/packages/headless/src/features/commerce/product-listing/product-listing-slice.test.ts +++ b/packages/headless/src/features/commerce/product-listing/product-listing-slice.test.ts @@ -1,4 +1,7 @@ -import type {ChildProduct} from '../../../api/commerce/common/product.js'; +import type { + ChildProduct, + Product, +} from '../../../api/commerce/common/product.js'; import {buildMockCommerceRegularFacetResponse} from '../../../test/mock-commerce-facet-response.js'; import { buildMockBaseProduct, @@ -6,6 +9,10 @@ import { buildMockProduct, } from '../../../test/mock-product.js'; import {buildFetchProductListingResponse} from '../../../test/mock-product-listing.js'; +import { + buildMockBaseSpotlightContent, + buildMockSpotlightContent, +} from '../../../test/mock-spotlight-content.js'; import {setError} from '../../error/error-actions.js'; import {setContext, setView} from '../context/context-actions.js'; import { @@ -31,181 +38,379 @@ describe('product-listing-slice', () => { }); describe('on #fetchProductListing.fulfilled', () => { - it('updates the product listing state with the received payload', () => { - const result = buildMockBaseProduct({ec_name: 'product1'}); - const facet = buildMockCommerceRegularFacetResponse(); - const responseId = 'some-response-id'; - const response = buildFetchProductListingResponse({ - products: [result], - facets: [facet], - responseId, + describe('when products list has entries', () => { + it('updates the product listing state with the received payload', () => { + const result = buildMockBaseProduct({ec_name: 'product1'}); + const facet = buildMockCommerceRegularFacetResponse(); + const responseId = 'some-response-id'; + const response = buildFetchProductListingResponse({ + products: [result], + facets: [facet], + responseId, + }); + + const action = fetchProductListing.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); + + expect(finalState.products).toEqual( + response.response.products.map((p) => + buildMockProduct({ec_name: p.ec_name, responseId}) + ) + ); + expect(finalState.facets[0]).toEqual(facet); + expect(finalState.responseId).toEqual(responseId); + expect(finalState.isLoading).toBe(false); }); - const action = fetchProductListing.fulfilled(response, ''); - const finalState = productListingReducer(state, action); - - expect(finalState.products).toEqual( - response.response.products.map((p) => - buildMockProduct({ec_name: p.ec_name, responseId}) - ) - ); - expect(finalState.facets[0]).toEqual(facet); - expect(finalState.responseId).toEqual(responseId); - expect(finalState.isLoading).toBe(false); - }); + it('sets the #position of each product to its 1-based position in the unpaginated list', () => { + const response = buildFetchProductListingResponse({ + products: [ + buildMockBaseProduct({ec_name: 'product1'}), + buildMockBaseProduct({ec_name: 'product2'}), + ], + pagination: { + page: 2, + perPage: 10, + totalEntries: 22, + totalPages: 3, + }, + }); + + const action = fetchProductListing.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); + + expect(finalState.products[0].position).toBe(21); + expect(finalState.products[1].position).toBe(22); + }); - it('sets the #position of each product to its 1-based position in the unpaginated list', () => { - const response = buildFetchProductListingResponse({ - products: [ + it('sets the responseId on each product', () => { + const products = [ buildMockBaseProduct({ec_name: 'product1'}), buildMockBaseProduct({ec_name: 'product2'}), - ], - pagination: { - page: 2, - perPage: 10, - totalEntries: 22, - totalPages: 3, - }, + ]; + const responseId = 'some-response-id'; + const response = buildFetchProductListingResponse({ + products, + responseId, + }); + + const action = fetchProductListing.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); + + expect(finalState.products[0].responseId).toBe(responseId); + expect(finalState.products[1].responseId).toBe(responseId); }); - const action = fetchProductListing.fulfilled(response, ''); - const finalState = productListingReducer(state, action); + it('set #error to null ', () => { + state.error = {message: 'message', statusCode: 500, type: 'type'}; - expect(finalState.products[0].position).toBe(21); - expect(finalState.products[1].position).toBe(22); + const response = buildFetchProductListingResponse(); + + const action = fetchProductListing.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); + expect(finalState.error).toBeNull(); + }); }); - it('sets the responseId on each product', () => { - const products = [ - buildMockBaseProduct({ec_name: 'product1'}), - buildMockBaseProduct({ec_name: 'product2'}), - ]; - const responseId = 'some-response-id'; - const response = buildFetchProductListingResponse({ - products, - responseId, + describe('when results list has entries', () => { + it('updates the results field with products and spotlight content', () => { + const product = buildMockBaseProduct({ec_name: 'product1'}); + const spotlight = buildMockBaseSpotlightContent({name: 'Spotlight 1'}); + const responseId = 'some-response-id'; + const response = buildFetchProductListingResponse({ + results: [product, spotlight], + responseId, + }); + + const action = fetchProductListing.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); + + expect(finalState.results).toHaveLength(2); + expect(finalState.results[0]).toEqual( + buildMockProduct({ec_name: 'product1', position: 1, responseId}) + ); + expect(finalState.results[1]).toEqual( + buildMockSpotlightContent({ + name: 'Spotlight 1', + position: 2, + responseId, + }) + ); }); - const action = fetchProductListing.fulfilled(response, ''); - const finalState = productListingReducer(state, action); + it('sets the #position of each result in results to its 1-based position', () => { + const product1 = buildMockBaseProduct({ec_name: 'product1'}); + const spotlight = buildMockBaseSpotlightContent({name: 'Spotlight 1'}); + const product2 = buildMockBaseProduct({ec_name: 'product2'}); + const response = buildFetchProductListingResponse({ + results: [product1, spotlight, product2], + pagination: { + page: 1, + perPage: 10, + totalEntries: 23, + totalPages: 3, + }, + }); + + const action = fetchProductListing.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); + + expect(finalState.results[0].position).toBe(11); + expect(finalState.results[1].position).toBe(12); + expect(finalState.results[2].position).toBe(13); + }); - expect(finalState.products[0].responseId).toBe(responseId); - expect(finalState.products[1].responseId).toBe(responseId); - }); + it('sets the responseId on each result in results', () => { + const product = buildMockBaseProduct({ec_name: 'product1'}); + const spotlight = buildMockBaseSpotlightContent({name: 'Spotlight 1'}); + const responseId = 'test-response-id'; + const response = buildFetchProductListingResponse({ + results: [product, spotlight], + responseId, + }); + + const action = fetchProductListing.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); - it('set #error to null ', () => { - const err = {message: 'message', statusCode: 500, type: 'type'}; - state.error = err; + expect(finalState.results[0].responseId).toBe(responseId); + expect(finalState.results[1].responseId).toBe(responseId); + }); - const response = buildFetchProductListingResponse(); + it('keeps results empty when response.results is empty', () => { + const product = buildMockBaseProduct({ec_name: 'product1'}); + const response = buildFetchProductListingResponse({ + products: [product], + results: [], + }); - const action = fetchProductListing.fulfilled(response, ''); - const finalState = productListingReducer(state, action); - expect(finalState.error).toBeNull(); + const action = fetchProductListing.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); + + expect(finalState.products).toHaveLength(1); + expect(finalState.results).toHaveLength(0); + }); }); }); describe('on #fetchMoreProducts.fulfilled', () => { - it('appends the received products to the product listing state', () => { - state.products = [ - buildMockProduct({ec_name: 'product1', responseId: 'old-response-id'}), - buildMockProduct({ec_name: 'product2', responseId: 'old-response-id'}), - ]; - const result = buildMockBaseProduct({ec_name: 'product3'}); - const facet = buildMockCommerceRegularFacetResponse(); - const responseId = 'some-response-id'; - const response = buildFetchProductListingResponse({ - products: [result], - facets: [facet], - responseId, + describe('when products list has entries', () => { + it('appends the received products to the product listing state', () => { + state.products = [ + buildMockProduct({ + ec_name: 'product1', + responseId: 'old-response-id', + }), + buildMockProduct({ + ec_name: 'product2', + responseId: 'old-response-id', + }), + ]; + const result = buildMockBaseProduct({ec_name: 'product3'}); + const facet = buildMockCommerceRegularFacetResponse(); + const responseId = 'some-response-id'; + const response = buildFetchProductListingResponse({ + products: [result], + facets: [facet], + responseId, + }); + + const action = fetchMoreProducts.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); + + expect(finalState.products.map((p) => p.ec_name)).toEqual([ + 'product1', + 'product2', + 'product3', + ]); + expect(finalState.facets).toEqual([facet]); + expect(finalState.responseId).toEqual(responseId); + expect(finalState.isLoading).toBe(false); }); - const action = fetchMoreProducts.fulfilled(response, ''); - const finalState = productListingReducer(state, action); - - expect(finalState.products.map((p) => p.ec_name)).toEqual([ - 'product1', - 'product2', - 'product3', - ]); - expect(finalState.facets).toEqual([facet]); - expect(finalState.responseId).toEqual(responseId); - expect(finalState.isLoading).toBe(false); - }); + it('sets the #position of each product to its 1-based position in the unpaginated list', () => { + state.products = [ + buildMockProduct({ + ec_name: 'product1', + position: 1, + }), + buildMockProduct({ + ec_name: 'product2', + position: 2, + }), + ]; + const response = buildFetchProductListingResponse({ + products: [buildMockBaseProduct({ec_name: 'product3'})], + pagination: { + page: 1, + perPage: 2, + totalEntries: 22, + totalPages: 3, + }, + }); + + const action = fetchMoreProducts.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); + + expect(finalState.products[0].position).toBe(1); + expect(finalState.products[1].position).toBe(2); + expect(finalState.products[2].position).toBe(3); + }); - it('sets the #position of each product to its 1-based position in the unpaginated list', () => { - state.products = [ - buildMockProduct({ - ec_name: 'product1', - position: 1, - }), - buildMockProduct({ - ec_name: 'product2', - position: 2, - }), - ]; - const response = buildFetchProductListingResponse({ - products: [buildMockBaseProduct({ec_name: 'product3'})], - pagination: { - page: 1, - perPage: 2, - totalEntries: 22, - totalPages: 3, - }, + it('sets the responseId on new products while preserving existing products responseId', () => { + state.products = [ + buildMockProduct({ + ec_name: 'product1', + position: 1, + responseId: 'old-response-id', + }), + buildMockProduct({ + ec_name: 'product2', + position: 2, + responseId: 'old-response-id', + }), + ]; + const newProduct = buildMockBaseProduct({ec_name: 'product3'}); + const responseId = 'new-response-id'; + const response = buildFetchProductListingResponse({ + products: [newProduct], + responseId, + pagination: { + page: 1, + perPage: 2, + totalEntries: 22, + totalPages: 3, + }, + }); + + const action = fetchMoreProducts.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); + + // Original products keep their responseId + expect(finalState.products[0].responseId).toBe('old-response-id'); + expect(finalState.products[1].responseId).toBe('old-response-id'); + // New products get the new responseId + expect(finalState.products[2].responseId).toBe(responseId); }); - const action = fetchMoreProducts.fulfilled(response, ''); - const finalState = productListingReducer(state, action); + it('set #error to null', () => { + state.error = {message: 'message', statusCode: 500, type: 'type'}; - expect(finalState.products[0].position).toBe(1); - expect(finalState.products[1].position).toBe(2); - expect(finalState.products[2].position).toBe(3); + const response = buildFetchProductListingResponse(); + const action = fetchMoreProducts.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); + expect(finalState.error).toBeNull(); + }); }); - it('sets the responseId on new products while preserving existing products responseId', () => { - state.products = [ - buildMockProduct({ + describe('when results list has entries', () => { + it('appends the received results (products and spotlight content) to the results state', () => { + const product1 = buildMockProduct({ ec_name: 'product1', - position: 1, responseId: 'old-response-id', - }), - buildMockProduct({ - ec_name: 'product2', + position: 1, + }); + const spotlight1 = buildMockSpotlightContent({ + name: 'Spotlight 1', position: 2, - responseId: 'old-response-id', - }), - ]; - const newProduct = buildMockBaseProduct({ec_name: 'product3'}); - const responseId = 'new-response-id'; - const response = buildFetchProductListingResponse({ - products: [newProduct], - responseId, - pagination: { - page: 1, - perPage: 2, - totalEntries: 22, - totalPages: 3, - }, + }); + state.results = [product1, spotlight1]; + + const product2 = buildMockBaseProduct({ec_name: 'product2'}); + const spotlight2 = buildMockBaseSpotlightContent({name: 'Spotlight 2'}); + const responseId = 'new-response-id'; + const response = buildFetchProductListingResponse({ + results: [product2, spotlight2], + responseId, + pagination: { + page: 1, + perPage: 2, + totalEntries: 4, + totalPages: 2, + }, + }); + + const action = fetchMoreProducts.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); + + expect(finalState.results).toHaveLength(4); + expect(finalState.results[0]).toEqual(product1); + expect(finalState.results[1]).toEqual(spotlight1); + expect(finalState.results[2]).toEqual( + buildMockProduct({ec_name: 'product2', position: 3, responseId}) + ); + expect(finalState.results[3]).toEqual( + buildMockSpotlightContent({ + name: 'Spotlight 2', + position: 4, + responseId, + }) + ); }); - const action = fetchMoreProducts.fulfilled(response, ''); - const finalState = productListingReducer(state, action); - - // Original products keep their responseId - expect(finalState.products[0].responseId).toBe('old-response-id'); - expect(finalState.products[1].responseId).toBe('old-response-id'); - // New products get the new responseId - expect(finalState.products[2].responseId).toBe(responseId); - }); - - it('set #error to null', () => { - const err = {message: 'message', statusCode: 500, type: 'type'}; - state.error = err; + it('sets the #position of each product in results to its 1-based position in the unpaginated list', () => { + const product1 = buildMockProduct({ + ec_name: 'product1', + position: 1, + }); + const spotlight = buildMockSpotlightContent({name: 'Spotlight 1'}); + state.results = [product1, spotlight]; + + const product2 = buildMockBaseProduct({ec_name: 'product2'}); + const response = buildFetchProductListingResponse({ + results: [product2], + pagination: { + page: 1, + perPage: 2, + totalEntries: 3, + totalPages: 2, + }, + }); + + const action = fetchMoreProducts.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); + + expect((finalState.results[0] as Product).position).toBe(1); + expect(finalState.results[1]).toEqual(spotlight); + expect((finalState.results[2] as Product).position).toBe(3); + }); - const response = buildFetchProductListingResponse(); - const action = fetchMoreProducts.fulfilled(response, ''); - const finalState = productListingReducer(state, action); - expect(finalState.error).toBeNull(); + it('sets the responseId on new results in results while preserving the responseId of the existing ones', () => { + const oldResponseId = 'old-response-id'; + const product1 = buildMockProduct({ + ec_name: 'product1', + position: 1, + responseId: oldResponseId, + }); + const spotlight1 = buildMockSpotlightContent({ + name: 'Spotlight 1', + position: 1, + responseId: oldResponseId, + }); + state.results = [product1, spotlight1]; + + const product2 = buildMockBaseProduct({ec_name: 'product2'}); + const spotlight = buildMockBaseSpotlightContent({name: 'Spotlight 1'}); + const responseId = 'new-response-id'; + const response = buildFetchProductListingResponse({ + results: [product2, spotlight], + responseId, + pagination: { + page: 1, + perPage: 1, + totalEntries: 3, + totalPages: 3, + }, + }); + + const action = fetchMoreProducts.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); + + expect(finalState.results).toHaveLength(4); + expect(finalState.results[0].responseId).toBe(oldResponseId); + expect(finalState.results[1].responseId).toBe(oldResponseId); + expect(finalState.results[2].responseId).toBe(responseId); + expect(finalState.results[3].responseId).toBe(responseId); + }); }); }); @@ -275,13 +480,13 @@ describe('product-listing-slice', () => { describe('on #fetchProductListing.pending', () => { it('sets #isLoading to true', () => { - const pendingAction = fetchProductListing.pending(''); + const pendingAction = fetchProductListing.pending('', {}); const finalState = productListingReducer(state, pendingAction); expect(finalState.isLoading).toBe(true); }); it('sets #requestId', () => { - const pendingAction = fetchProductListing.pending('request-id'); + const pendingAction = fetchProductListing.pending('request-id', {}); const finalState = productListingReducer(state, pendingAction); expect(finalState.requestId).toBe('request-id'); }); @@ -289,13 +494,13 @@ describe('product-listing-slice', () => { describe('on #fetchMoreProducts.pending', () => { it('sets #isLoading to true', () => { - const pendingAction = fetchMoreProducts.pending(''); + const pendingAction = fetchMoreProducts.pending('', {}); const finalState = productListingReducer(state, pendingAction); expect(finalState.isLoading).toBe(true); }); it('sets #requestId', () => { - const pendingAction = fetchMoreProducts.pending('request-id'); + const pendingAction = fetchMoreProducts.pending('request-id', {}); const finalState = productListingReducer(state, pendingAction); expect(finalState.requestId).toBe('request-id'); }); @@ -328,55 +533,134 @@ describe('product-listing-slice', () => { expect(finalState).toEqual(state); }); - it('when both parent and child exist, promotes the child to parent', () => { - const childProduct = buildMockChildProduct({ - permanentid, - additionalFields: {test: 'test'}, - clickUri: 'child-uri', - ec_brand: 'child brand', - ec_category: ['child category'], - ec_description: 'child description', - ec_gender: 'child gender', - ec_images: ['child image'], - ec_in_stock: false, - ec_item_group_id: 'child item group id', - ec_name: 'child name', - ec_product_id: 'child product id', - ec_promo_price: 1, - ec_rating: 1, - ec_shortdesc: 'child short description', - ec_thumbnails: ['child thumbnail'], - ec_price: 2, + describe('when products list has entries', () => { + it('when both parent and child exist, promotes the child to parent', () => { + const childProduct = buildMockChildProduct({ + permanentid, + additionalFields: {test: 'test'}, + clickUri: 'child-uri', + ec_brand: 'child brand', + ec_category: ['child category'], + ec_description: 'child description', + ec_gender: 'child gender', + ec_images: ['child image'], + ec_in_stock: false, + ec_item_group_id: 'child item group id', + ec_name: 'child name', + ec_product_id: 'child product id', + ec_promo_price: 1, + ec_rating: 1, + ec_shortdesc: 'child short description', + ec_thumbnails: ['child thumbnail'], + ec_price: 2, + }); + + const parentProduct = buildMockProduct({ + permanentid: parentPermanentId, + children: [childProduct], + totalNumberOfChildren: 1, + position: 5, + responseId: 'test-response-id', + }); + + state.products = [parentProduct]; + + const finalState = productListingReducer(state, action); + + expect(finalState.products).toEqual([ + buildMockProduct({ + ...childProduct, + children: parentProduct.children, + totalNumberOfChildren: parentProduct.totalNumberOfChildren, + position: parentProduct.position, + responseId: parentProduct.responseId, + }), + ]); }); + }); - const parentProduct = buildMockProduct({ - permanentid: parentPermanentId, - children: [childProduct], - totalNumberOfChildren: 1, - position: 5, - responseId: 'test-response-id', + describe('when results list has entries', () => { + it('when both parent and child exist in results, promotes the child to parent', () => { + const childProduct = buildMockChildProduct({ + permanentid, + additionalFields: {test: 'test'}, + clickUri: 'child-uri', + ec_brand: 'child brand', + ec_category: ['child category'], + ec_description: 'child description', + ec_gender: 'child gender', + ec_images: ['child image'], + ec_in_stock: false, + ec_item_group_id: 'child item group id', + ec_name: 'child name', + ec_product_id: 'child product id', + ec_promo_price: 1, + ec_rating: 1, + ec_shortdesc: 'child short description', + ec_thumbnails: ['child thumbnail'], + ec_price: 2, + }); + + const parentProduct = buildMockProduct({ + permanentid: parentPermanentId, + children: [childProduct], + totalNumberOfChildren: 1, + position: 3, + responseId: 'test-response-id', + }); + + const spotlight = buildMockSpotlightContent({name: 'Spotlight 1'}); + state.results = [parentProduct, spotlight]; + + const finalState = productListingReducer(state, action); + + expect(finalState.results).toHaveLength(2); + expect(finalState.results[0]).toEqual( + buildMockProduct({ + ...childProduct, + children: parentProduct.children, + totalNumberOfChildren: parentProduct.totalNumberOfChildren, + position: parentProduct.position, + responseId: parentProduct.responseId, + }) + ); + expect(finalState.results[1]).toEqual(spotlight); }); - state.products = [parentProduct]; + it('when results contain spotlight content, skips spotlight when searching for parent', () => { + const childProduct = buildMockChildProduct({ + permanentid, + ec_name: 'child name', + }); - const finalState = productListingReducer(state, action); + const parentProduct = buildMockProduct({ + permanentid: parentPermanentId, + children: [childProduct], + totalNumberOfChildren: 1, + position: 2, + responseId: 'test-response-id', + }); + + const spotlight = buildMockSpotlightContent({name: 'Spotlight 1'}); + state.results = [spotlight, parentProduct]; - expect(finalState.products).toEqual([ - buildMockProduct({ - ...childProduct, - children: parentProduct.children, - totalNumberOfChildren: parentProduct.totalNumberOfChildren, - position: parentProduct.position, - responseId: parentProduct.responseId, - }), - ]); + const finalState = productListingReducer(state, action); + + expect(finalState.results).toHaveLength(2); + expect(finalState.results[0]).toEqual(spotlight); + expect((finalState.results[1] as Product).permanentid).toBe( + permanentid + ); + }); }); }); + it('on #setView, restores the initial state', () => { state = { error: {message: 'error', statusCode: 500, type: 'type'}, isLoading: true, products: [buildMockProduct({ec_name: 'product1'})], + results: [], facets: [buildMockCommerceRegularFacetResponse()], responseId: 'response-id', requestId: 'request-id', @@ -392,6 +676,7 @@ describe('product-listing-slice', () => { error: {message: 'error', statusCode: 500, type: 'type'}, isLoading: true, products: [buildMockProduct({ec_name: 'product1'})], + results: [], facets: [buildMockCommerceRegularFacetResponse()], responseId: 'response-id', requestId: 'request-id', diff --git a/packages/headless/src/features/commerce/product-listing/product-listing-slice.ts b/packages/headless/src/features/commerce/product-listing/product-listing-slice.ts index b9785cd04fd..d00076d043c 100644 --- a/packages/headless/src/features/commerce/product-listing/product-listing-slice.ts +++ b/packages/headless/src/features/commerce/product-listing/product-listing-slice.ts @@ -5,7 +5,14 @@ import type { ChildProduct, Product, } from '../../../api/commerce/common/product.js'; -import type {CommerceSuccessResponse} from '../../../api/commerce/common/response.js'; +import { + type BaseResult, + type BaseSpotlightContent, + type Result, + ResultType, + type SpotlightContent, +} from '../../../api/commerce/common/result.js'; +import type {ListingCommerceSuccessResponse} from '../../../api/commerce/listing/response.js'; import {setError} from '../../error/error-actions.js'; import {setContext, setView} from '../context/context-actions.js'; import { @@ -32,14 +39,16 @@ export const productListingReducer = createReducer( }) .addCase(fetchProductListing.fulfilled, (state, action) => { const paginationOffset = getPaginationOffset(action.payload); - handleFullfilled(state, action.payload.response); - state.products = action.payload.response.products.map( - (product, index) => - preprocessProduct( - product, - paginationOffset + index + 1, - action.payload.response.responseId - ) + handleFulfilled(state, action.payload.response); + state.products = mapPreprocessedProducts( + action.payload.response.products, + paginationOffset, + action.payload.response.responseId + ); + state.results = mapPreprocessedResults( + action.payload.response.results, + paginationOffset, + action.payload.response.responseId ); }) .addCase(fetchMoreProducts.fulfilled, (state, action) => { @@ -47,14 +56,19 @@ export const productListingReducer = createReducer( return; } const paginationOffset = getPaginationOffset(action.payload); - handleFullfilled(state, action.payload.response); + handleFulfilled(state, action.payload.response); state.products = state.products.concat( - action.payload.response.products.map((product, index) => - preprocessProduct( - product, - paginationOffset + index + 1, - action.payload?.response.responseId - ) + mapPreprocessedProducts( + action.payload.response.products, + paginationOffset, + action.payload.response.responseId + ) + ); + state.results = state.results.concat( + mapPreprocessedResults( + action.payload.response.results, + paginationOffset, + action.payload.response.responseId ) ); }) @@ -65,32 +79,42 @@ export const productListingReducer = createReducer( handlePending(state, action.meta.requestId); }) .addCase(promoteChildToParent, (state, action) => { - const {products} = state; + const productsOrResults = + state.results.length > 0 ? state.results : state.products; let childToPromote: ChildProduct | undefined; - const currentParentIndex = products.findIndex((product) => { - childToPromote = product.children.find( + const currentParentIndex = productsOrResults.findIndex((result) => { + if (result.resultType === ResultType.SPOTLIGHT) { + return false; + } + childToPromote = result.children.find( (child) => child.permanentid === action.payload.child.permanentid ); return !!childToPromote; }); - if (currentParentIndex === -1 || childToPromote === undefined) { + const currentParent = productsOrResults[currentParentIndex]; + if ( + currentParentIndex === -1 || + childToPromote === undefined || + currentParent.resultType === ResultType.SPOTLIGHT + ) { return; } - const responseId = products[currentParentIndex].responseId; - const position = products[currentParentIndex].position; - const {children, totalNumberOfChildren} = products[currentParentIndex]; + const responseId = currentParent.responseId; + const position = currentParent.position; + const {children, totalNumberOfChildren} = currentParent; const newParent: Product = { ...(childToPromote as ChildProduct), + resultType: ResultType.PRODUCT, children, totalNumberOfChildren, position, responseId, }; - products.splice(currentParentIndex, 1, newParent); + productsOrResults.splice(currentParentIndex, 1, newParent); }) .addCase(setView, () => getProductListingInitialState()) .addCase(setContext, () => getProductListingInitialState()) @@ -108,9 +132,9 @@ function handleError( state.isLoading = false; } -function handleFullfilled( +function handleFulfilled( state: ProductListingState, - response: CommerceSuccessResponse + response: ListingCommerceSuccessResponse ) { state.error = null; state.facets = response.facets; @@ -128,6 +152,37 @@ function getPaginationOffset(payload: QueryCommerceAPIThunkReturn): number { return pagination.page * pagination.perPage; } +function mapPreprocessedProducts( + products: BaseProduct[], + paginationOffset: number, + responseId?: string +): Product[] { + return products.map((product, index) => + preprocessProduct(product, paginationOffset + index + 1, responseId) + ); +} + +function mapPreprocessedResults( + results: BaseResult[], + paginationOffset: number, + responseId?: string +): Result[] { + return results.map((result, index) => + preprocessResult(result, paginationOffset + index + 1, responseId) + ); +} + +function preprocessResult( + result: BaseResult, + position: number, + responseId?: string +): Result { + if (result.resultType === ResultType.SPOTLIGHT) { + return preprocessSpotlightContent(result, position, responseId); + } + return preprocessProduct(result, position, responseId); +} + function preprocessProduct( product: BaseProduct, position: number, @@ -153,3 +208,15 @@ function preprocessProduct( responseId, }; } + +function preprocessSpotlightContent( + spotlight: BaseSpotlightContent, + position: number, + responseId?: string +): SpotlightContent { + return { + ...spotlight, + position, + responseId, + }; +} diff --git a/packages/headless/src/features/commerce/product-listing/product-listing-state.ts b/packages/headless/src/features/commerce/product-listing/product-listing-state.ts index 2307937709a..08b7e3dde04 100644 --- a/packages/headless/src/features/commerce/product-listing/product-listing-state.ts +++ b/packages/headless/src/features/commerce/product-listing/product-listing-state.ts @@ -1,5 +1,6 @@ import type {CommerceAPIErrorStatusResponse} from '../../../api/commerce/commerce-api-error-response.js'; import type {Product} from '../../../api/commerce/common/product.js'; +import type {Result} from '../../../api/commerce/common/result.js'; import type {AnyFacetResponse} from '../facets/facet-set/interfaces/response.js'; export interface ProductListingState { @@ -9,6 +10,7 @@ export interface ProductListingState { responseId: string; facets: AnyFacetResponse[]; products: Product[]; + results: Result[]; } export const getProductListingInitialState = (): ProductListingState => ({ @@ -18,4 +20,5 @@ export const getProductListingInitialState = (): ProductListingState => ({ responseId: '', facets: [], products: [], + results: [], }); diff --git a/packages/headless/src/features/commerce/recommendations/recommendations-slice.ts b/packages/headless/src/features/commerce/recommendations/recommendations-slice.ts index e59a025c03e..15aaad9dcd5 100644 --- a/packages/headless/src/features/commerce/recommendations/recommendations-slice.ts +++ b/packages/headless/src/features/commerce/recommendations/recommendations-slice.ts @@ -5,6 +5,7 @@ import type { ChildProduct, Product, } from '../../../api/commerce/common/product.js'; +import {ResultType} from '../../../api/commerce/common/result.js'; import type {RecommendationsCommerceSuccessResponse} from '../../../api/commerce/recommendations/recommendations-response.js'; import {setError} from '../../error/error-actions.js'; import { @@ -125,6 +126,7 @@ export const recommendationsReducer = createReducer( const newParent: Product = { ...(childToPromote as ChildProduct), + resultType: ResultType.PRODUCT, children, totalNumberOfChildren, position, diff --git a/packages/headless/src/features/commerce/search/search-slice.ts b/packages/headless/src/features/commerce/search/search-slice.ts index bfecc7fd843..abd280aa4c2 100644 --- a/packages/headless/src/features/commerce/search/search-slice.ts +++ b/packages/headless/src/features/commerce/search/search-slice.ts @@ -6,6 +6,7 @@ import type { Product, } from '../../../api/commerce/common/product.js'; import type {CommerceSuccessResponse} from '../../../api/commerce/common/response.js'; +import {ResultType} from '../../../api/commerce/common/result.js'; import {setError} from '../../error/error-actions.js'; import {setContext, setView} from '../context/context-actions.js'; import { @@ -92,6 +93,7 @@ export const commerceSearchReducer = createReducer( const newParent: Product = { ...(childToPromote as ChildProduct), + resultType: ResultType.PRODUCT, children, totalNumberOfChildren, position, diff --git a/packages/headless/src/ssr-commerce.index.ts b/packages/headless/src/ssr-commerce.index.ts index 60634e4c9a7..9defce41622 100644 --- a/packages/headless/src/ssr-commerce.index.ts +++ b/packages/headless/src/ssr-commerce.index.ts @@ -1,5 +1,5 @@ /** - * The Coveo Headless SSR Commerce sub-package exposes exposes the engine, definers, controllers, actions, and utility functions to build a server side rendered commerce experience. + * The Coveo Headless SSR Commerce sub-package exposes the engine, definers, controllers, actions, and utility functions to build a server side rendered commerce experience. * * @example * ```typescript @@ -353,6 +353,7 @@ export type { ChildProduct, Product, } from './api/commerce/common/product.js'; +export {ResultType} from './api/commerce/common/result.js'; export { getAnalyticsNextApiBaseUrl, getOrganizationEndpoint, diff --git a/packages/headless/src/test/mock-product-listing.ts b/packages/headless/src/test/mock-product-listing.ts index 81bc1070ed0..46f2a606a35 100644 --- a/packages/headless/src/test/mock-product-listing.ts +++ b/packages/headless/src/test/mock-product-listing.ts @@ -1,9 +1,9 @@ -import type {CommerceSuccessResponse} from '../api/commerce/common/response.js'; +import type {ListingCommerceSuccessResponse} from '../api/commerce/listing/response.js'; import type {QueryCommerceAPIThunkReturn} from '../features/commerce/product-listing/product-listing-actions.js'; import {SortBy} from '../features/sort/sort.js'; export function buildFetchProductListingResponse( - response: Partial = {} + response: Partial = {} ): QueryCommerceAPIThunkReturn { return { response: { @@ -19,6 +19,7 @@ export function buildFetchProductListingResponse( }, facets: response.facets ?? [], products: response.products ?? [], + results: response.results ?? [], responseId: response.responseId ?? '', triggers: response.triggers ?? [], }, diff --git a/packages/headless/src/test/mock-product.ts b/packages/headless/src/test/mock-product.ts index df23b08b364..8c04a6ef28c 100644 --- a/packages/headless/src/test/mock-product.ts +++ b/packages/headless/src/test/mock-product.ts @@ -3,6 +3,7 @@ import type { ChildProduct, Product, } from '../api/commerce/common/product.js'; +import {ResultType} from '../api/commerce/common/result.js'; export function buildMockChildProduct( config: Partial = {} @@ -27,6 +28,7 @@ export function buildMockChildProduct( ec_rating: 0, ec_shortdesc: '', ec_thumbnails: [], + resultType: ResultType.CHILD_PRODUCT, ...config, }; } @@ -37,6 +39,7 @@ export function buildMockBaseProduct( const {children, totalNumberOfChildren, ...childProductConfig} = config; return { ...buildMockChildProduct(childProductConfig), + resultType: ResultType.PRODUCT, children: children ?? [], totalNumberOfChildren: totalNumberOfChildren ?? 0, }; diff --git a/packages/headless/src/test/mock-spotlight-content.ts b/packages/headless/src/test/mock-spotlight-content.ts new file mode 100644 index 00000000000..b6880bd004a --- /dev/null +++ b/packages/headless/src/test/mock-spotlight-content.ts @@ -0,0 +1,30 @@ +import type { + BaseSpotlightContent, + SpotlightContent, +} from '../api/commerce/common/result.js'; +import {ResultType} from '../api/commerce/common/result.js'; + +export function buildMockBaseSpotlightContent( + config: Partial = {} +): BaseSpotlightContent { + return { + id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + clickUri: 'https://example.com/spotlight', + desktopImage: 'https://example.com/desktop.jpg', + mobileImage: 'https://example.com/mobile.jpg', + name: 'Spotlight Content', + description: 'A spotlight description', + resultType: ResultType.SPOTLIGHT, + ...config, + }; +} + +export function buildMockSpotlightContent( + config: Partial = {} +): SpotlightContent { + const {position, ...baseSpotlightContentConfig} = config; + return { + ...buildMockBaseSpotlightContent(baseSpotlightContentConfig), + position: position ?? 1, + }; +}