From c40870ad8ded662f4bde7c4167d9be46d9073239 Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 25 Nov 2025 15:43:37 +0000 Subject: [PATCH 01/41] wip --- .../src/api/commerce/commerce-api-client.ts | 18 ++++--- .../src/api/commerce/commerce-api-params.ts | 4 ++ .../src/api/commerce/common/product.ts | 5 ++ .../src/api/commerce/common/result.ts | 37 ++++++++++++++ .../src/api/commerce/listing/request.ts | 5 ++ .../src/api/commerce/listing/response.ts | 7 +++ .../commerce-engine-configuration.ts | 6 +++ .../headless-product-listing.ts | 21 ++++++-- .../product-listing-actions.ts | 22 ++++---- .../product-listing-selectors.ts | 5 +- .../product-listing/product-listing-slice.ts | 50 ++++++++++++++----- .../product-listing/product-listing-state.ts | 3 ++ .../headless/src/test/mock-product-listing.ts | 5 +- 13 files changed, 154 insertions(+), 34 deletions(-) create mode 100644 packages/headless/src/api/commerce/common/result.ts create mode 100644 packages/headless/src/api/commerce/listing/request.ts create mode 100644 packages/headless/src/api/commerce/listing/response.ts 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..e16676ceed9 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 {ResultType} from './result.js'; export type ChildProduct = Omit< BaseProduct, @@ -143,6 +144,10 @@ export interface BaseProduct { * The ID of the response that returned the product. */ responseId?: string; + /** + * The result type of the product. + */ + resultType: ResultType.PRODUCT | ResultType.CHILD_PRODUCT; } export interface Product extends 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..02b89c0aebb --- /dev/null +++ b/packages/headless/src/api/commerce/common/result.ts @@ -0,0 +1,37 @@ +import type {BaseProduct, Product} from './product.js'; + +export enum ResultType { + CHILD_PRODUCT = 'childProduct', + PRODUCT = 'product', + SPOTLIGHT = 'spotlight', +} + +export interface SpotlightContent { + /** + * 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 result type identifier, always SPOTLIGHT for spotlight content. + */ + resultType: ResultType.SPOTLIGHT; +} + +export type BaseResult = BaseProduct | SpotlightContent; +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/app/commerce-engine/commerce-engine-configuration.ts b/packages/headless/src/app/commerce-engine/commerce-engine-configuration.ts index c8923076f86..303487dc349 100644 --- a/packages/headless/src/app/commerce-engine/commerce-engine-configuration.ts +++ b/packages/headless/src/app/commerce-engine/commerce-engine-configuration.ts @@ -39,6 +39,12 @@ export interface CommerceEngineConfiguration extends EngineConfiguration { * See [Headless proxy: Commerce](https://docs.coveo.com/en/headless/latest/usage/proxy#commerce). **/ proxyBaseUrl?: string; + /** + * When set to true, fills the `results` field rather than the `products` field + * in the controller state. It may also include Spotlight Content in the results. + * @default false + */ + enableResults?: boolean; } export const commerceEngineConfigurationSchema = 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..a8e04d052d1 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 @@ -102,16 +102,31 @@ export interface ProductListingState { responseId: string; } +export interface ProductListingOptions { + /** + * When set to true, fills the `results` field rather than the `products` field + * in the controller state. It may also include Spotlight Content in the results. + * @default false + */ + enableResults?: boolean; +} + /** * 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, + options: ProductListingOptions = { + enableResults: false, + } +): ProductListing { if (!loadBaseProductListingReducers(engine)) { throw loadReducerError; } @@ -157,7 +172,7 @@ export function buildProductListing(engine: CommerceEngine): ProductListing { dispatch(promoteChildToParent({child})); }, - refresh: () => dispatch(fetchProductListing()), + refresh: () => dispatch(fetchProductListing(options)), executeFirstRequest() { const firstRequestExecuted = responseIdSelector(getState()) !== ''; @@ -166,7 +181,7 @@ export function buildProductListing(engine: CommerceEngine): ProductListing { return; } - dispatch(fetchProductListing()); + dispatch(fetchProductListing(options)); }, }; } 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..da084a7496b 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,8 @@ 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 {ProductListingOptions} from '../../../controllers/commerce/product-listing/headless-product-listing.js'; import type {ProductListingSection} from '../../../state/state-sections.js'; import {validatePayload} from '../../../utils/validate-payload.js'; import { @@ -20,7 +21,7 @@ import { export interface QueryCommerceAPIThunkReturn { /** The successful response. */ - response: CommerceSuccessResponse; + response: ListingCommerceSuccessResponse; } export type StateNeededByFetchProductListing = @@ -28,18 +29,20 @@ export type StateNeededByFetchProductListing = export const fetchProductListing = createAsyncThunk< QueryCommerceAPIThunkReturn, - void, + ProductListingOptions, AsyncThunkCommerceOptions >( 'commerce/productListing/fetch', async ( - _action, + options, {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(options?.enableResults), + }); if (isErrorResponse(fetched)) { return rejectWithValue(fetched.error); @@ -53,12 +56,12 @@ export const fetchProductListing = createAsyncThunk< export const fetchMoreProducts = createAsyncThunk< QueryCommerceAPIThunkReturn | null, - void, + ProductListingOptions, AsyncThunkCommerceOptions >( 'commerce/productListing/fetchMoreProducts', async ( - _action, + options, {getState, rejectWithValue, extra: {apiClient, navigatorContext}} ) => { const state = getState(); @@ -72,6 +75,7 @@ export const fetchMoreProducts = createAsyncThunk< const fetched = await apiClient.getProductListing({ ...buildFilterableCommerceAPIRequest(state, navigatorContext), + enableResults: Boolean(options?.enableResults), page: nextPageToRequest, }); 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..e721f7ea921 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?.products.length || + state.productListing?.results.length || + 0; export const moreProductsAvailableSelector = createSelector( (state: Partial) => ({ 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..abe2e66ba0f 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,8 @@ import type { ChildProduct, 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 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,7 +33,7 @@ export const productListingReducer = createReducer( }) .addCase(fetchProductListing.fulfilled, (state, action) => { const paginationOffset = getPaginationOffset(action.payload); - handleFullfilled(state, action.payload.response); + handleFulfilled(state, action.payload.response); state.products = action.payload.response.products.map( (product, index) => preprocessProduct( @@ -41,13 +42,22 @@ export const productListingReducer = createReducer( action.payload.response.responseId ) ); + state.results = action.payload.response.results.map((result, index) => + result.resultType === ResultType.SPOTLIGHT + ? result + : preprocessProduct( + result, + paginationOffset + index + 1, + action.payload.response.responseId + ) + ); }) .addCase(fetchMoreProducts.fulfilled, (state, action) => { if (!action.payload) { 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( @@ -57,6 +67,17 @@ export const productListingReducer = createReducer( ) ) ); + state.results = state.results.concat( + action.payload.response.results.map((result, index) => + result.resultType === ResultType.SPOTLIGHT + ? result + : preprocessProduct( + result, + paginationOffset + index + 1, + action.payload!.response.responseId + ) + ) + ); }) .addCase(fetchProductListing.pending, (state, action) => { handlePending(state, action.meta.requestId); @@ -65,10 +86,14 @@ export const productListingReducer = createReducer( handlePending(state, action.meta.requestId); }) .addCase(promoteChildToParent, (state, action) => { - const {products} = state; + const {products, results} = state; + const productsOrResults = results.length > 0 ? results : 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; @@ -78,9 +103,10 @@ export const productListingReducer = createReducer( return; } - const responseId = products[currentParentIndex].responseId; - const position = products[currentParentIndex].position; - const {children, totalNumberOfChildren} = products[currentParentIndex]; + const currentParent = results[currentParentIndex] as Product; + const responseId = currentParent.responseId; + const position = currentParent.position; + const {children, totalNumberOfChildren} = currentParent; const newParent: Product = { ...(childToPromote as ChildProduct), @@ -90,7 +116,7 @@ export const productListingReducer = createReducer( responseId, }; - products.splice(currentParentIndex, 1, newParent); + productsOrResults.splice(currentParentIndex, 1, newParent); }) .addCase(setView, () => getProductListingInitialState()) .addCase(setContext, () => getProductListingInitialState()) @@ -108,9 +134,9 @@ function handleError( state.isLoading = false; } -function handleFullfilled( +function handleFulfilled( state: ProductListingState, - response: CommerceSuccessResponse + response: ListingCommerceSuccessResponse ) { state.error = null; state.facets = response.facets; 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/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 ?? [], }, From 57428c9a36b07fc38617d1e0bdbff3c152180531 Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 25 Nov 2025 16:09:16 +0000 Subject: [PATCH 02/41] basic typesafe version --- .../headless-product-listing.ts | 19 ++++- .../product-listing-actions.ts | 75 +++++++++++++++++-- 2 files changed, 83 insertions(+), 11 deletions(-) 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 a8e04d052d1..22381213644 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 @@ -18,7 +18,9 @@ import {activeParametersSelector} from '../../../features/commerce/parameters/pa import {productListingSerializer} from '../../../features/commerce/parameters/parameters-serializer.js'; import { fetchMoreProducts, + fetchMoreResults, fetchProductListing, + fetchResultsListing, promoteChildToParent, } from '../../../features/commerce/product-listing/product-listing-actions.js'; import { @@ -136,8 +138,12 @@ export function buildProductListing( const getState = () => engine[stateKey]; const subControllers = buildProductListingSubControllers(engine, { responseIdSelector, - fetchProductsActionCreator: fetchProductListing, - fetchMoreProductsActionCreator: fetchMoreProducts, + fetchProductsActionCreator: options.enableResults + ? fetchResultsListing + : fetchProductListing, + fetchMoreProductsActionCreator: options.enableResults + ? fetchMoreResults + : fetchMoreProducts, facetResponseSelector, isFacetLoadingResponseSelector, requestIdSelector, @@ -172,7 +178,10 @@ export function buildProductListing( dispatch(promoteChildToParent({child})); }, - refresh: () => dispatch(fetchProductListing(options)), + refresh: () => + dispatch( + options.enableResults ? fetchResultsListing() : fetchProductListing() + ), executeFirstRequest() { const firstRequestExecuted = responseIdSelector(getState()) !== ''; @@ -181,7 +190,9 @@ export function buildProductListing( return; } - dispatch(fetchProductListing(options)); + dispatch( + options.enableResults ? fetchResultsListing() : fetchProductListing() + ); }, }; } 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 da084a7496b..4cf15d4e2e3 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 @@ -6,7 +6,6 @@ import { } from '../../../api/commerce/commerce-api-client.js'; import type {ChildProduct} from '../../../api/commerce/common/product.js'; import type {ListingCommerceSuccessResponse} from '../../../api/commerce/listing/response.js'; -import type {ProductListingOptions} from '../../../controllers/commerce/product-listing/headless-product-listing.js'; import type {ProductListingSection} from '../../../state/state-sections.js'; import {validatePayload} from '../../../utils/validate-payload.js'; import { @@ -29,19 +28,46 @@ export type StateNeededByFetchProductListing = export const fetchProductListing = createAsyncThunk< QueryCommerceAPIThunkReturn, - ProductListingOptions, + void, AsyncThunkCommerceOptions >( 'commerce/productListing/fetch', async ( - options, + _action, {getState, rejectWithValue, extra: {apiClient, navigatorContext}} ) => { const state = getState(); const request = buildFilterableCommerceAPIRequest(state, navigatorContext); const fetched = await apiClient.getProductListing({ ...request, - enableResults: Boolean(options?.enableResults), + enableResults: false, + }); + + if (isErrorResponse(fetched)) { + return rejectWithValue(fetched.error); + } + + return { + response: fetched.success, + }; + } +); + +export const fetchResultsListing = createAsyncThunk< + QueryCommerceAPIThunkReturn, + void, + AsyncThunkCommerceOptions +>( + 'commerce/productListing/fetch', + async ( + _action, + {getState, rejectWithValue, extra: {apiClient, navigatorContext}} + ) => { + const state = getState(); + const request = buildFilterableCommerceAPIRequest(state, navigatorContext); + const fetched = await apiClient.getProductListing({ + ...request, + enableResults: true, }); if (isErrorResponse(fetched)) { @@ -56,12 +82,47 @@ export const fetchProductListing = createAsyncThunk< export const fetchMoreProducts = createAsyncThunk< QueryCommerceAPIThunkReturn | null, - ProductListingOptions, + void, + AsyncThunkCommerceOptions +>( + 'commerce/productListing/fetchMoreProducts', + async ( + _actions, + {getState, rejectWithValue, extra: {apiClient, navigatorContext}} + ) => { + const state = getState(); + const moreProductsAvailable = moreProductsAvailableSelector(state); + if (!moreProductsAvailable) { + return null; + } + const perPage = perPagePrincipalSelector(state); + const numberOfProducts = numberOfProductsSelector(state); + const nextPageToRequest = numberOfProducts / perPage; + + const fetched = await apiClient.getProductListing({ + ...buildFilterableCommerceAPIRequest(state, navigatorContext), + enableResults: false, + page: nextPageToRequest, + }); + + if (isErrorResponse(fetched)) { + return rejectWithValue(fetched.error); + } + + return { + response: fetched.success, + }; + } +); + +export const fetchMoreResults = createAsyncThunk< + QueryCommerceAPIThunkReturn | null, + void, AsyncThunkCommerceOptions >( 'commerce/productListing/fetchMoreProducts', async ( - options, + _action, {getState, rejectWithValue, extra: {apiClient, navigatorContext}} ) => { const state = getState(); @@ -75,7 +136,7 @@ export const fetchMoreProducts = createAsyncThunk< const fetched = await apiClient.getProductListing({ ...buildFilterableCommerceAPIRequest(state, navigatorContext), - enableResults: Boolean(options?.enableResults), + enableResults: true, page: nextPageToRequest, }); From 0b1036b37372b1b271252e918f82a52564dd027a Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 25 Nov 2025 16:15:54 +0000 Subject: [PATCH 03/41] refactor to dedupe a bit --- .../headless-product-listing.ts | 25 +-- .../product-listing-actions.ts | 195 +++++++----------- 2 files changed, 82 insertions(+), 138 deletions(-) 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 22381213644..25b95470339 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 @@ -17,10 +17,8 @@ import {parametersDefinition} from '../../../features/commerce/parameters/parame import {activeParametersSelector} from '../../../features/commerce/parameters/parameters-selectors.js'; import {productListingSerializer} from '../../../features/commerce/parameters/parameters-serializer.js'; import { - fetchMoreProducts, - fetchMoreResults, - fetchProductListing, - fetchResultsListing, + createFetchMoreProductsThunk, + createFetchProductListingThunk, promoteChildToParent, } from '../../../features/commerce/product-listing/product-listing-actions.js'; import { @@ -136,14 +134,12 @@ export function buildProductListing( const controller = buildController(engine); const {dispatch} = engine; const getState = () => engine[stateKey]; + const enableResults = options.enableResults ?? false; + const subControllers = buildProductListingSubControllers(engine, { responseIdSelector, - fetchProductsActionCreator: options.enableResults - ? fetchResultsListing - : fetchProductListing, - fetchMoreProductsActionCreator: options.enableResults - ? fetchMoreResults - : fetchMoreProducts, + fetchProductsActionCreator: createFetchProductListingThunk(enableResults), + fetchMoreProductsActionCreator: createFetchMoreProductsThunk(enableResults), facetResponseSelector, isFacetLoadingResponseSelector, requestIdSelector, @@ -178,10 +174,7 @@ export function buildProductListing( dispatch(promoteChildToParent({child})); }, - refresh: () => - dispatch( - options.enableResults ? fetchResultsListing() : fetchProductListing() - ), + refresh: () => dispatch(createFetchProductListingThunk(enableResults)()), executeFirstRequest() { const firstRequestExecuted = responseIdSelector(getState()) !== ''; @@ -190,9 +183,7 @@ export function buildProductListing( return; } - dispatch( - options.enableResults ? fetchResultsListing() : fetchProductListing() - ); + dispatch(createFetchProductListingThunk(enableResults)()); }, }; } 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 4cf15d4e2e3..53e53563e2d 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 @@ -26,129 +26,82 @@ export interface QueryCommerceAPIThunkReturn { export type StateNeededByFetchProductListing = StateNeededForFilterableCommerceAPIRequest & ProductListingSection; -export const fetchProductListing = createAsyncThunk< - QueryCommerceAPIThunkReturn, - void, - AsyncThunkCommerceOptions ->( - 'commerce/productListing/fetch', - async ( - _action, - {getState, rejectWithValue, extra: {apiClient, navigatorContext}} - ) => { - const state = getState(); - const request = buildFilterableCommerceAPIRequest(state, navigatorContext); - const fetched = await apiClient.getProductListing({ - ...request, - enableResults: false, - }); - - if (isErrorResponse(fetched)) { - return rejectWithValue(fetched.error); +/** + * Creates a fetch product listing thunk with the specified enableResults flag. + * @param enableResults - Whether to enable results mode (includes spotlight content) + * @returns An async thunk action creator + */ +export const createFetchProductListingThunk = (enableResults: boolean) => + createAsyncThunk< + QueryCommerceAPIThunkReturn, + void, + AsyncThunkCommerceOptions + >( + 'commerce/productListing/fetch', + async ( + _action, + {getState, rejectWithValue, extra: {apiClient, navigatorContext}} + ) => { + const state = getState(); + const request = buildFilterableCommerceAPIRequest( + state, + navigatorContext + ); + const fetched = await apiClient.getProductListing({ + ...request, + enableResults, + }); + + if (isErrorResponse(fetched)) { + return rejectWithValue(fetched.error); + } + + return { + response: fetched.success, + }; } - - return { - response: fetched.success, - }; - } -); - -export const fetchResultsListing = createAsyncThunk< - QueryCommerceAPIThunkReturn, - void, - AsyncThunkCommerceOptions ->( - 'commerce/productListing/fetch', - async ( - _action, - {getState, rejectWithValue, extra: {apiClient, navigatorContext}} - ) => { - const state = getState(); - const request = buildFilterableCommerceAPIRequest(state, navigatorContext); - const fetched = await apiClient.getProductListing({ - ...request, - enableResults: true, - }); - - if (isErrorResponse(fetched)) { - return rejectWithValue(fetched.error); + ); + +/** + * Creates a fetch more products thunk with the specified enableResults flag. + * @param enableResults - Whether to enable results mode (includes spotlight content) + * @returns An async thunk action creator + */ +export const createFetchMoreProductsThunk = (enableResults: boolean) => + createAsyncThunk< + QueryCommerceAPIThunkReturn | null, + void, + AsyncThunkCommerceOptions + >( + 'commerce/productListing/fetchMoreProducts', + async ( + _action, + {getState, rejectWithValue, extra: {apiClient, navigatorContext}} + ) => { + const state = getState(); + const moreProductsAvailable = moreProductsAvailableSelector(state); + if (!moreProductsAvailable) { + return null; + } + const perPage = perPagePrincipalSelector(state); + const numberOfProducts = numberOfProductsSelector(state); + const nextPageToRequest = numberOfProducts / perPage; + + const fetched = await apiClient.getProductListing({ + ...buildFilterableCommerceAPIRequest(state, navigatorContext), + enableResults, + page: nextPageToRequest, + }); + + if (isErrorResponse(fetched)) { + return rejectWithValue(fetched.error); + } + + return { + response: fetched.success, + }; } - - return { - response: fetched.success, - }; - } -); - -export const fetchMoreProducts = createAsyncThunk< - QueryCommerceAPIThunkReturn | null, - void, - AsyncThunkCommerceOptions ->( - 'commerce/productListing/fetchMoreProducts', - async ( - _actions, - {getState, rejectWithValue, extra: {apiClient, navigatorContext}} - ) => { - const state = getState(); - const moreProductsAvailable = moreProductsAvailableSelector(state); - if (!moreProductsAvailable) { - return null; - } - const perPage = perPagePrincipalSelector(state); - const numberOfProducts = numberOfProductsSelector(state); - const nextPageToRequest = numberOfProducts / perPage; - - const fetched = await apiClient.getProductListing({ - ...buildFilterableCommerceAPIRequest(state, navigatorContext), - enableResults: false, - page: nextPageToRequest, - }); - - if (isErrorResponse(fetched)) { - return rejectWithValue(fetched.error); - } - - return { - response: fetched.success, - }; - } -); - -export const fetchMoreResults = createAsyncThunk< - QueryCommerceAPIThunkReturn | null, - void, - AsyncThunkCommerceOptions ->( - 'commerce/productListing/fetchMoreProducts', - async ( - _action, - {getState, rejectWithValue, extra: {apiClient, navigatorContext}} - ) => { - const state = getState(); - const moreProductsAvailable = moreProductsAvailableSelector(state); - if (!moreProductsAvailable) { - return null; - } - const perPage = perPagePrincipalSelector(state); - const numberOfProducts = numberOfProductsSelector(state); - const nextPageToRequest = numberOfProducts / perPage; - - const fetched = await apiClient.getProductListing({ - ...buildFilterableCommerceAPIRequest(state, navigatorContext), - enableResults: true, - page: nextPageToRequest, - }); - - if (isErrorResponse(fetched)) { - return rejectWithValue(fetched.error); - } - - return { - response: fetched.success, - }; - } -); + ); export interface PromoteChildToParentPayload { child: ChildProduct; From c616a44aa483faa7e3e6306e5977933b838a2d2d Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 25 Nov 2025 16:25:53 +0000 Subject: [PATCH 04/41] refactor types a bit --- .../headless-product-listing.ts | 9 ++++----- .../product-listing-actions.ts | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 13 deletions(-) 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 25b95470339..b4e9bfa1011 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 @@ -134,12 +134,11 @@ export function buildProductListing( const controller = buildController(engine); const {dispatch} = engine; const getState = () => engine[stateKey]; - const enableResults = options.enableResults ?? false; const subControllers = buildProductListingSubControllers(engine, { responseIdSelector, - fetchProductsActionCreator: createFetchProductListingThunk(enableResults), - fetchMoreProductsActionCreator: createFetchMoreProductsThunk(enableResults), + fetchProductsActionCreator: createFetchProductListingThunk(options), + fetchMoreProductsActionCreator: createFetchMoreProductsThunk(options), facetResponseSelector, isFacetLoadingResponseSelector, requestIdSelector, @@ -174,7 +173,7 @@ export function buildProductListing( dispatch(promoteChildToParent({child})); }, - refresh: () => dispatch(createFetchProductListingThunk(enableResults)()), + refresh: () => dispatch(createFetchProductListingThunk(options)()), executeFirstRequest() { const firstRequestExecuted = responseIdSelector(getState()) !== ''; @@ -183,7 +182,7 @@ export function buildProductListing( return; } - dispatch(createFetchProductListingThunk(enableResults)()); + dispatch(createFetchProductListingThunk(options)()); }, }; } 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 53e53563e2d..f06fff035a1 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 @@ -6,6 +6,7 @@ import { } from '../../../api/commerce/commerce-api-client.js'; import type {ChildProduct} from '../../../api/commerce/common/product.js'; import type {ListingCommerceSuccessResponse} from '../../../api/commerce/listing/response.js'; +import type {ProductListingOptions} from '../../../controllers/commerce/product-listing/headless-product-listing.js'; import type {ProductListingSection} from '../../../state/state-sections.js'; import {validatePayload} from '../../../utils/validate-payload.js'; import { @@ -27,11 +28,13 @@ export type StateNeededByFetchProductListing = StateNeededForFilterableCommerceAPIRequest & ProductListingSection; /** - * Creates a fetch product listing thunk with the specified enableResults flag. - * @param enableResults - Whether to enable results mode (includes spotlight content) + * Creates a fetch product listing thunk with the specified options. + * @param options - The product listing options * @returns An async thunk action creator */ -export const createFetchProductListingThunk = (enableResults: boolean) => +export const createFetchProductListingThunk = ( + options: ProductListingOptions +) => createAsyncThunk< QueryCommerceAPIThunkReturn, void, @@ -49,7 +52,7 @@ export const createFetchProductListingThunk = (enableResults: boolean) => ); const fetched = await apiClient.getProductListing({ ...request, - enableResults, + enableResults: Boolean(options.enableResults), }); if (isErrorResponse(fetched)) { @@ -63,11 +66,11 @@ export const createFetchProductListingThunk = (enableResults: boolean) => ); /** - * Creates a fetch more products thunk with the specified enableResults flag. - * @param enableResults - Whether to enable results mode (includes spotlight content) + * Creates a fetch more products thunk with the specified options. + * @param options - The product listing options * @returns An async thunk action creator */ -export const createFetchMoreProductsThunk = (enableResults: boolean) => +export const createFetchMoreProductsThunk = (options: ProductListingOptions) => createAsyncThunk< QueryCommerceAPIThunkReturn | null, void, @@ -89,7 +92,7 @@ export const createFetchMoreProductsThunk = (enableResults: boolean) => const fetched = await apiClient.getProductListing({ ...buildFilterableCommerceAPIRequest(state, navigatorContext), - enableResults, + enableResults: Boolean(options.enableResults), page: nextPageToRequest, }); From d8a47d52e0aeaccfc4f9dc2998e56b49aad35247 Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 25 Nov 2025 16:52:36 +0000 Subject: [PATCH 05/41] see if this compiles --- .../sub-controller/headless-sub-controller.ts | 27 +++- .../headless-product-listing.ts | 14 +- .../product-listing-actions.ts | 133 ++++++++---------- 3 files changed, 91 insertions(+), 83 deletions(-) diff --git a/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts b/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts index 293604ab9c0..2d6c8d2071f 100644 --- a/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts +++ b/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts @@ -149,6 +149,7 @@ interface BaseSubControllerProps { fetchMoreProductsActionCreator: FetchProductsActionCreator; enrichSummary?: (state: CommerceEngineState) => Partial; slotId?: string; + enableResults?: boolean; } export interface SearchAndListingSubControllerProps< @@ -240,6 +241,7 @@ export function buildSearchAndListingsSubControllers< ): SearchAndListingSubControllers { const { fetchProductsActionCreator, + fetchMoreProductsActionCreator, facetResponseSelector, isFacetLoadingResponseSelector, requestIdSelector, @@ -248,18 +250,31 @@ export function buildSearchAndListingsSubControllers< activeParametersSelector, restoreActionCreator, facetSearchType, + enableResults, } = subControllerProps; + + // TODO: check what this wrapping is doing + // Wrap action creators to include enableResults + const wrappedFetchProducts = () => + fetchProductsActionCreator({enableResults}); + const wrappedFetchMoreProducts = () => + fetchMoreProductsActionCreator({enableResults}); + return { - ...buildBaseSubControllers(engine, subControllerProps), + ...buildBaseSubControllers(engine, { + ...subControllerProps, + fetchProductsActionCreator: wrappedFetchProducts, + fetchMoreProductsActionCreator: wrappedFetchMoreProducts, + }), sort(props?: SortProps) { return buildCoreSort(engine, { ...props, - fetchProductsActionCreator, + fetchProductsActionCreator: wrappedFetchProducts, }); }, facetGenerator() { const commonOptions = { - fetchProductsActionCreator, + fetchProductsActionCreator: wrappedFetchProducts, facetResponseSelector, isFacetLoadingResponseSelector, facetSearch: {type: facetSearchType}, @@ -275,13 +290,13 @@ export function buildSearchAndListingsSubControllers< buildCategoryFacet(engine, {...options, ...commonOptions}), buildLocationFacet: (_engine, options) => buildCommerceLocationFacet(engine, {...options, ...commonOptions}), - fetchProductsActionCreator, + fetchProductsActionCreator: wrappedFetchProducts, }); }, breadcrumbManager() { return buildCoreBreadcrumbManager(engine, { facetResponseSelector, - fetchProductsActionCreator, + fetchProductsActionCreator: wrappedFetchProducts, }); }, urlManager(props: UrlManagerProps) { @@ -299,7 +314,7 @@ export function buildSearchAndListingsSubControllers< parametersDefinition, activeParametersSelector, restoreActionCreator, - fetchProductsActionCreator, + fetchProductsActionCreator: wrappedFetchProducts, }); }, }; 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 b4e9bfa1011..0eb9174d5fe 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 @@ -17,8 +17,8 @@ import {parametersDefinition} from '../../../features/commerce/parameters/parame import {activeParametersSelector} from '../../../features/commerce/parameters/parameters-selectors.js'; import {productListingSerializer} from '../../../features/commerce/parameters/parameters-serializer.js'; import { - createFetchMoreProductsThunk, - createFetchProductListingThunk, + fetchMoreProducts, + fetchProductListing, promoteChildToParent, } from '../../../features/commerce/product-listing/product-listing-actions.js'; import { @@ -134,11 +134,12 @@ export function buildProductListing( const controller = buildController(engine); const {dispatch} = engine; const getState = () => engine[stateKey]; + const enableResults = options.enableResults ?? false; const subControllers = buildProductListingSubControllers(engine, { responseIdSelector, - fetchProductsActionCreator: createFetchProductListingThunk(options), - fetchMoreProductsActionCreator: createFetchMoreProductsThunk(options), + fetchProductsActionCreator: () => fetchProductListing({enableResults}), + fetchMoreProductsActionCreator: () => fetchMoreProducts({enableResults}), facetResponseSelector, isFacetLoadingResponseSelector, requestIdSelector, @@ -152,6 +153,7 @@ export function buildProductListing( perPageSelector: perPagePrincipalSelector, totalEntriesSelector: totalEntriesPrincipalSelector, numberOfProductsSelector, + enableResults, }); return { @@ -173,7 +175,7 @@ export function buildProductListing( dispatch(promoteChildToParent({child})); }, - refresh: () => dispatch(createFetchProductListingThunk(options)()), + refresh: () => dispatch(fetchProductListing({enableResults})), executeFirstRequest() { const firstRequestExecuted = responseIdSelector(getState()) !== ''; @@ -182,7 +184,7 @@ export function buildProductListing( return; } - dispatch(createFetchProductListingThunk(options)()); + dispatch(fetchProductListing({enableResults})); }, }; } 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 f06fff035a1..3b306236f7f 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 @@ -6,7 +6,6 @@ import { } from '../../../api/commerce/commerce-api-client.js'; import type {ChildProduct} from '../../../api/commerce/common/product.js'; import type {ListingCommerceSuccessResponse} from '../../../api/commerce/listing/response.js'; -import type {ProductListingOptions} from '../../../controllers/commerce/product-listing/headless-product-listing.js'; import type {ProductListingSection} from '../../../state/state-sections.js'; import {validatePayload} from '../../../utils/validate-payload.js'; import { @@ -27,84 +26,76 @@ export interface QueryCommerceAPIThunkReturn { export type StateNeededByFetchProductListing = StateNeededForFilterableCommerceAPIRequest & ProductListingSection; -/** - * Creates a fetch product listing thunk with the specified options. - * @param options - The product listing options - * @returns An async thunk action creator - */ -export const createFetchProductListingThunk = ( - options: ProductListingOptions -) => - createAsyncThunk< - QueryCommerceAPIThunkReturn, - void, - AsyncThunkCommerceOptions - >( - 'commerce/productListing/fetch', - async ( - _action, - {getState, rejectWithValue, extra: {apiClient, navigatorContext}} - ) => { - const state = getState(); - const request = buildFilterableCommerceAPIRequest( - state, - navigatorContext - ); - const fetched = await apiClient.getProductListing({ - ...request, - enableResults: Boolean(options.enableResults), - }); +export type 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; +}; - if (isErrorResponse(fetched)) { - return rejectWithValue(fetched.error); - } +export const fetchProductListing = createAsyncThunk< + QueryCommerceAPIThunkReturn, + FetchProductListingPayload, + AsyncThunkCommerceOptions +>( + 'commerce/productListing/fetch', + async ( + arg, + {getState, rejectWithValue, extra: {apiClient, navigatorContext}} + ) => { + const state = getState(); + const request = buildFilterableCommerceAPIRequest(state, navigatorContext); + const fetched = await apiClient.getProductListing({ + ...request, + enableResults: Boolean(arg?.enableResults), + }); - return { - response: fetched.success, - }; + if (isErrorResponse(fetched)) { + return rejectWithValue(fetched.error); } - ); -/** - * Creates a fetch more products thunk with the specified options. - * @param options - The product listing options - * @returns An async thunk action creator - */ -export const createFetchMoreProductsThunk = (options: ProductListingOptions) => - createAsyncThunk< - QueryCommerceAPIThunkReturn | null, - void, - AsyncThunkCommerceOptions - >( - 'commerce/productListing/fetchMoreProducts', - async ( - _action, - {getState, rejectWithValue, extra: {apiClient, navigatorContext}} - ) => { - const state = getState(); - const moreProductsAvailable = moreProductsAvailableSelector(state); - if (!moreProductsAvailable) { - return null; - } - const perPage = perPagePrincipalSelector(state); - const numberOfProducts = numberOfProductsSelector(state); - const nextPageToRequest = numberOfProducts / perPage; + return { + response: fetched.success, + }; + } +); - const fetched = await apiClient.getProductListing({ - ...buildFilterableCommerceAPIRequest(state, navigatorContext), - enableResults: Boolean(options.enableResults), - page: nextPageToRequest, - }); +export const fetchMoreProducts = createAsyncThunk< + QueryCommerceAPIThunkReturn | null, + FetchProductListingPayload, + AsyncThunkCommerceOptions +>( + 'commerce/productListing/fetchMoreProducts', + async ( + arg, + {getState, rejectWithValue, extra: {apiClient, navigatorContext}} + ) => { + const state = getState(); + const moreProductsAvailable = moreProductsAvailableSelector(state); + if (!moreProductsAvailable) { + return null; + } + const perPage = perPagePrincipalSelector(state); + const numberOfProducts = numberOfProductsSelector(state); + const nextPageToRequest = numberOfProducts / perPage; - if (isErrorResponse(fetched)) { - return rejectWithValue(fetched.error); - } + const fetched = await apiClient.getProductListing({ + ...buildFilterableCommerceAPIRequest(state, navigatorContext), + enableResults: Boolean(arg?.enableResults), + page: nextPageToRequest, + }); - return { - response: fetched.success, - }; + if (isErrorResponse(fetched)) { + return rejectWithValue(fetched.error); } - ); + + return { + response: fetched.success, + }; + } +); export interface PromoteChildToParentPayload { child: ChildProduct; From c9ae7e04764ed3bceeb08b791c131cdb1d8e46a5 Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 25 Nov 2025 17:01:36 +0000 Subject: [PATCH 06/41] fix types --- .../src/controllers/commerce/core/common.ts | 8 +++----- .../product-listing-actions-loader.ts | 13 +++++++++---- .../product-listing/product-listing-actions.ts | 12 ++++++------ packages/headless/src/test/mock-product.ts | 3 +++ 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/headless/src/controllers/commerce/core/common.ts b/packages/headless/src/controllers/commerce/core/common.ts index 286e5fa8285..840a9b56794 100644 --- a/packages/headless/src/controllers/commerce/core/common.ts +++ b/packages/headless/src/controllers/commerce/core/common.ts @@ -5,11 +5,9 @@ import type { } from '@reduxjs/toolkit'; import type {AsyncThunkOptions} from '../../../app/async-thunk-options.js'; -export type FetchProductsActionCreator = () => AsyncThunkAction< - unknown, - unknown, - AsyncThunkOptions ->; +export type FetchProductsActionCreator = ( + options?: unknown +) => AsyncThunkAction>; export type ToggleActionCreator = PayloadActionCreator< unknown, 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..cdf5b98cf24 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 @@ -3,6 +3,7 @@ import type {AsyncThunkCommerceOptions} from '../../../api/commerce/commerce-api 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, fetchMoreProducts, fetchProductListing, type PromoteChildToParentPayload, @@ -23,9 +24,11 @@ export interface ProductListingActionCreators { * * @returns A dispatchable action. */ - fetchProductListing(): AsyncThunkAction< + fetchProductListing( + payload: FetchProductListingPayload + ): AsyncThunkAction< QueryCommerceAPIThunkReturn, - void, + FetchProductListingPayload, AsyncThunkCommerceOptions >; @@ -34,9 +37,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 3b306236f7f..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 @@ -26,14 +26,14 @@ export interface QueryCommerceAPIThunkReturn { export type StateNeededByFetchProductListing = StateNeededForFilterableCommerceAPIRequest & ProductListingSection; -export type FetchProductListingPayload = { +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, @@ -42,14 +42,14 @@ export const fetchProductListing = createAsyncThunk< >( 'commerce/productListing/fetch', async ( - arg, + payload, {getState, rejectWithValue, extra: {apiClient, navigatorContext}} ) => { const state = getState(); const request = buildFilterableCommerceAPIRequest(state, navigatorContext); const fetched = await apiClient.getProductListing({ ...request, - enableResults: Boolean(arg?.enableResults), + enableResults: Boolean(payload?.enableResults), }); if (isErrorResponse(fetched)) { @@ -69,7 +69,7 @@ export const fetchMoreProducts = createAsyncThunk< >( 'commerce/productListing/fetchMoreProducts', async ( - arg, + payload, {getState, rejectWithValue, extra: {apiClient, navigatorContext}} ) => { const state = getState(); @@ -83,7 +83,7 @@ export const fetchMoreProducts = createAsyncThunk< const fetched = await apiClient.getProductListing({ ...buildFilterableCommerceAPIRequest(state, navigatorContext), - enableResults: Boolean(arg?.enableResults), + enableResults: Boolean(payload?.enableResults), page: nextPageToRequest, }); 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, }; From dc5d00aa5aa3981af0fe8752a4b8738d4f8c49c8 Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 25 Nov 2025 17:15:21 +0000 Subject: [PATCH 07/41] is this working? --- .../src/__tests__/mock-products.ts | 1 + .../src/controllers/commerce/core/common.ts | 8 +++--- .../sub-controller/headless-sub-controller.ts | 25 +++++-------------- 3 files changed, 12 insertions(+), 22 deletions(-) diff --git a/packages/headless-react/src/__tests__/mock-products.ts b/packages/headless-react/src/__tests__/mock-products.ts index b5c0d0056a2..33f269c9b13 100644 --- a/packages/headless-react/src/__tests__/mock-products.ts +++ b/packages/headless-react/src/__tests__/mock-products.ts @@ -24,6 +24,7 @@ const createMockProduct = (overrides: Partial = {}): Product => ({ permanentid: randomUUID(), position: 1, totalNumberOfChildren: 0, + resultType: 'product', // todo: figure out how to import enum ...overrides, }); diff --git a/packages/headless/src/controllers/commerce/core/common.ts b/packages/headless/src/controllers/commerce/core/common.ts index 840a9b56794..286e5fa8285 100644 --- a/packages/headless/src/controllers/commerce/core/common.ts +++ b/packages/headless/src/controllers/commerce/core/common.ts @@ -5,9 +5,11 @@ import type { } from '@reduxjs/toolkit'; import type {AsyncThunkOptions} from '../../../app/async-thunk-options.js'; -export type FetchProductsActionCreator = ( - options?: unknown -) => AsyncThunkAction>; +export type FetchProductsActionCreator = () => AsyncThunkAction< + unknown, + unknown, + AsyncThunkOptions +>; export type ToggleActionCreator = PayloadActionCreator< unknown, diff --git a/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts b/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts index 2d6c8d2071f..638df4ab6a7 100644 --- a/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts +++ b/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts @@ -241,7 +241,6 @@ export function buildSearchAndListingsSubControllers< ): SearchAndListingSubControllers { const { fetchProductsActionCreator, - fetchMoreProductsActionCreator, facetResponseSelector, isFacetLoadingResponseSelector, requestIdSelector, @@ -250,31 +249,19 @@ export function buildSearchAndListingsSubControllers< activeParametersSelector, restoreActionCreator, facetSearchType, - enableResults, } = subControllerProps; - // TODO: check what this wrapping is doing - // Wrap action creators to include enableResults - const wrappedFetchProducts = () => - fetchProductsActionCreator({enableResults}); - const wrappedFetchMoreProducts = () => - fetchMoreProductsActionCreator({enableResults}); - return { - ...buildBaseSubControllers(engine, { - ...subControllerProps, - fetchProductsActionCreator: wrappedFetchProducts, - fetchMoreProductsActionCreator: wrappedFetchMoreProducts, - }), + ...buildBaseSubControllers(engine, subControllerProps), sort(props?: SortProps) { return buildCoreSort(engine, { ...props, - fetchProductsActionCreator: wrappedFetchProducts, + fetchProductsActionCreator, }); }, facetGenerator() { const commonOptions = { - fetchProductsActionCreator: wrappedFetchProducts, + fetchProductsActionCreator, facetResponseSelector, isFacetLoadingResponseSelector, facetSearch: {type: facetSearchType}, @@ -290,13 +277,13 @@ export function buildSearchAndListingsSubControllers< buildCategoryFacet(engine, {...options, ...commonOptions}), buildLocationFacet: (_engine, options) => buildCommerceLocationFacet(engine, {...options, ...commonOptions}), - fetchProductsActionCreator: wrappedFetchProducts, + fetchProductsActionCreator, }); }, breadcrumbManager() { return buildCoreBreadcrumbManager(engine, { facetResponseSelector, - fetchProductsActionCreator: wrappedFetchProducts, + fetchProductsActionCreator, }); }, urlManager(props: UrlManagerProps) { @@ -314,7 +301,7 @@ export function buildSearchAndListingsSubControllers< parametersDefinition, activeParametersSelector, restoreActionCreator, - fetchProductsActionCreator: wrappedFetchProducts, + fetchProductsActionCreator, }); }, }; From 2404f1d335305d582bc415cbf08f8a8f97b7c67f Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 25 Nov 2025 17:17:12 +0000 Subject: [PATCH 08/41] reduce diff --- .../commerce/core/sub-controller/headless-sub-controller.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts b/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts index 638df4ab6a7..293604ab9c0 100644 --- a/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts +++ b/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts @@ -149,7 +149,6 @@ interface BaseSubControllerProps { fetchMoreProductsActionCreator: FetchProductsActionCreator; enrichSummary?: (state: CommerceEngineState) => Partial; slotId?: string; - enableResults?: boolean; } export interface SearchAndListingSubControllerProps< @@ -250,7 +249,6 @@ export function buildSearchAndListingsSubControllers< restoreActionCreator, facetSearchType, } = subControllerProps; - return { ...buildBaseSubControllers(engine, subControllerProps), sort(props?: SortProps) { From a75aa105fcc3898d7e352db54012fa81c730a87a Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 25 Nov 2025 17:17:46 +0000 Subject: [PATCH 09/41] refactor a default value --- .../commerce/product-listing/headless-product-listing.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 0eb9174d5fe..c9b575953d4 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 @@ -123,7 +123,7 @@ export interface ProductListingOptions { */ export function buildProductListing( engine: CommerceEngine, - options: ProductListingOptions = { + {enableResults = false}: ProductListingOptions = { enableResults: false, } ): ProductListing { @@ -134,7 +134,6 @@ export function buildProductListing( const controller = buildController(engine); const {dispatch} = engine; const getState = () => engine[stateKey]; - const enableResults = options.enableResults ?? false; const subControllers = buildProductListingSubControllers(engine, { responseIdSelector, From c4d8341f6bdb2d9b337e331950f21b6e68a02744 Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 25 Nov 2025 17:18:34 +0000 Subject: [PATCH 10/41] dedupe an option --- .../product-listing/headless-product-listing.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) 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 c9b575953d4..a58f0287f3e 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 @@ -17,6 +17,7 @@ import {parametersDefinition} from '../../../features/commerce/parameters/parame import {activeParametersSelector} from '../../../features/commerce/parameters/parameters-selectors.js'; import {productListingSerializer} from '../../../features/commerce/parameters/parameters-serializer.js'; import { + type FetchProductListingPayload, fetchMoreProducts, fetchProductListing, promoteChildToParent, @@ -102,14 +103,7 @@ export interface ProductListingState { responseId: string; } -export interface ProductListingOptions { - /** - * When set to true, fills the `results` field rather than the `products` field - * in the controller state. It may also include Spotlight Content in the results. - * @default false - */ - enableResults?: boolean; -} +export interface ProductListingOptions extends FetchProductListingPayload {} /** * Creates a `ProductListing` controller instance. @@ -152,7 +146,6 @@ export function buildProductListing( perPageSelector: perPagePrincipalSelector, totalEntriesSelector: totalEntriesPrincipalSelector, numberOfProductsSelector, - enableResults, }); return { From 72011ea8fa1787238fe5232e1d48a04e51a53af2 Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 25 Nov 2025 17:20:13 +0000 Subject: [PATCH 11/41] update comment --- packages/headless-react/src/__tests__/mock-products.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/headless-react/src/__tests__/mock-products.ts b/packages/headless-react/src/__tests__/mock-products.ts index 33f269c9b13..ceb50cd33a2 100644 --- a/packages/headless-react/src/__tests__/mock-products.ts +++ b/packages/headless-react/src/__tests__/mock-products.ts @@ -24,7 +24,7 @@ const createMockProduct = (overrides: Partial = {}): Product => ({ permanentid: randomUUID(), position: 1, totalNumberOfChildren: 0, - resultType: 'product', // todo: figure out how to import enum + resultType: 'product', // todo: figure out how to import enum. Do i need string literal? ...overrides, }); From 8d9acedf33a2b58ecddeaf715d320e26fa6d50f3 Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 09:41:08 +0000 Subject: [PATCH 12/41] fix a type --- packages/headless-react/src/__tests__/mock-products.ts | 3 ++- packages/headless/src/ssr-commerce.index.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/headless-react/src/__tests__/mock-products.ts b/packages/headless-react/src/__tests__/mock-products.ts index ceb50cd33a2..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,7 +25,7 @@ const createMockProduct = (overrides: Partial = {}): Product => ({ permanentid: randomUUID(), position: 1, totalNumberOfChildren: 0, - resultType: 'product', // todo: figure out how to import enum. Do i need string literal? + resultType: ResultType.PRODUCT, ...overrides, }); 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, From f8280c348c0a1a229ede2d4a9be982659faf02c1 Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 09:46:42 +0000 Subject: [PATCH 13/41] fix a type --- .../product-listing/product-listing-actions-loader.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 cdf5b98cf24..5828f9693be 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 @@ -25,7 +25,7 @@ export interface ProductListingActionCreators { * @returns A dispatchable action. */ fetchProductListing( - payload: FetchProductListingPayload + payload?: FetchProductListingPayload ): AsyncThunkAction< QueryCommerceAPIThunkReturn, FetchProductListingPayload, @@ -38,7 +38,7 @@ export interface ProductListingActionCreators { * @returns A dispatchable action. */ fetchMoreProducts( - payload: FetchProductListingPayload + payload?: FetchProductListingPayload ): AsyncThunkAction< QueryCommerceAPIThunkReturn | null, FetchProductListingPayload, From 215145aab138e38fea710534bc21e5b40493124a Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 09:49:59 +0000 Subject: [PATCH 14/41] checkpoint version with no build errors From 7c4fdb3ba15e53733c66cac28643217923379c73 Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 10:08:11 +0000 Subject: [PATCH 15/41] fix slice tests --- .../src/api/commerce/commerce-api-client.test.ts | 16 +++++++++++++--- .../instant-products/instant-products-slice.ts | 2 ++ .../recommendations/recommendations-slice.ts | 2 ++ .../src/features/commerce/search/search-slice.ts | 2 ++ 4 files changed, 19 insertions(+), 3 deletions(-) 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/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/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, From 4be83f8939ec5cde08f35c984416ce4a112640e5 Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 10:23:34 +0000 Subject: [PATCH 16/41] fix product listing slice --- .../product-listing-slice.test.ts | 50 ++++++++++++++----- .../product-listing/product-listing-slice.ts | 17 ++----- 2 files changed, 42 insertions(+), 25 deletions(-) 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..0f2a20afbae 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,4 @@ -import type {ChildProduct} from '../../../api/commerce/common/product.js'; +import type {BaseProduct, ChildProduct} from '../../../api/commerce/common/product.js'; import {buildMockCommerceRegularFacetResponse} from '../../../test/mock-commerce-facet-response.js'; import { buildMockBaseProduct, @@ -18,6 +18,10 @@ import { getProductListingInitialState, type ProductListingState, } from './product-listing-state.js'; +import {ResultType, Result, BaseResult} from '../../../api/commerce/common/result.js'; +import { +Product +} from '../../../api/commerce/common/product.js'; describe('product-listing-slice', () => { let state: ProductListingState; @@ -41,7 +45,7 @@ describe('product-listing-slice', () => { responseId, }); - const action = fetchProductListing.fulfilled(response, ''); + const action = fetchProductListing.fulfilled(response, '', {}); const finalState = productListingReducer(state, action); expect(finalState.products).toEqual( @@ -68,7 +72,7 @@ describe('product-listing-slice', () => { }, }); - const action = fetchProductListing.fulfilled(response, ''); + const action = fetchProductListing.fulfilled(response, '', {}); const finalState = productListingReducer(state, action); expect(finalState.products[0].position).toBe(21); @@ -86,7 +90,7 @@ describe('product-listing-slice', () => { responseId, }); - const action = fetchProductListing.fulfilled(response, ''); + const action = fetchProductListing.fulfilled(response, '', {}); const finalState = productListingReducer(state, action); expect(finalState.products[0].responseId).toBe(responseId); @@ -99,7 +103,7 @@ describe('product-listing-slice', () => { const response = buildFetchProductListingResponse(); - const action = fetchProductListing.fulfilled(response, ''); + const action = fetchProductListing.fulfilled(response, '', {}); const finalState = productListingReducer(state, action); expect(finalState.error).toBeNull(); }); @@ -120,7 +124,7 @@ describe('product-listing-slice', () => { responseId, }); - const action = fetchMoreProducts.fulfilled(response, ''); + const action = fetchMoreProducts.fulfilled(response, '', {}); const finalState = productListingReducer(state, action); expect(finalState.products.map((p) => p.ec_name)).toEqual([ @@ -154,7 +158,7 @@ describe('product-listing-slice', () => { }, }); - const action = fetchMoreProducts.fulfilled(response, ''); + const action = fetchMoreProducts.fulfilled(response, '', {}); const finalState = productListingReducer(state, action); expect(finalState.products[0].position).toBe(1); @@ -188,7 +192,7 @@ describe('product-listing-slice', () => { }, }); - const action = fetchMoreProducts.fulfilled(response, ''); + const action = fetchMoreProducts.fulfilled(response, '', {}); const finalState = productListingReducer(state, action); // Original products keep their responseId @@ -203,7 +207,7 @@ describe('product-listing-slice', () => { state.error = err; const response = buildFetchProductListingResponse(); - const action = fetchMoreProducts.fulfilled(response, ''); + const action = fetchMoreProducts.fulfilled(response, '', {}); const finalState = productListingReducer(state, action); expect(finalState.error).toBeNull(); }); @@ -275,13 +279,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 +293,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'); }); @@ -377,6 +381,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', @@ -392,6 +397,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', @@ -427,3 +433,21 @@ describe('product-listing-slice', () => { }); }); }); + +const getProductsFromResults = (results: Result[]): Array => { + const products: Array = []; + for (const result of results) { + products.push(result.resultType !== ResultType.SPOTLIGHT ? result : null); + } + return products; +}; + +const getBaseProductsFromBaseResults = ( + results: BaseResult[] +): Array => { + const products: Array = []; + for (const result of results) { + products.push(result.resultType !== ResultType.SPOTLIGHT ? result : null); + } + return products; +}; 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 abe2e66ba0f..1bcbf9f5c83 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 @@ -1,10 +1,6 @@ import {createReducer} from '@reduxjs/toolkit'; import type {CommerceAPIErrorStatusResponse} from '../../../api/commerce/commerce-api-error-response.js'; -import type { - BaseProduct, - ChildProduct, - Product, -} from '../../../api/commerce/common/product.js'; +import type {BaseProduct, ChildProduct, Product,} from '../../../api/commerce/common/product.js'; import {ResultType} from '../../../api/commerce/common/result.js'; import type {ListingCommerceSuccessResponse} from '../../../api/commerce/listing/response.js'; import {setError} from '../../error/error-actions.js'; @@ -15,10 +11,7 @@ import { promoteChildToParent, type QueryCommerceAPIThunkReturn, } from './product-listing-actions.js'; -import { - getProductListingInitialState, - type ProductListingState, -} from './product-listing-state.js'; +import {getProductListingInitialState, type ProductListingState,} from './product-listing-state.js'; export const productListingReducer = createReducer( getProductListingInitialState(), @@ -86,8 +79,7 @@ export const productListingReducer = createReducer( handlePending(state, action.meta.requestId); }) .addCase(promoteChildToParent, (state, action) => { - const {products, results} = state; - const productsOrResults = results.length > 0 ? results : products; + const productsOrResults = state.results.length > 0 ? state.results : state.products; let childToPromote: ChildProduct | undefined; const currentParentIndex = productsOrResults.findIndex((result) => { if (result.resultType === ResultType.SPOTLIGHT) { @@ -103,13 +95,14 @@ export const productListingReducer = createReducer( return; } - const currentParent = results[currentParentIndex] as Product; + const currentParent = productsOrResults[currentParentIndex] as Product; const responseId = currentParent.responseId; const position = currentParent.position; const {children, totalNumberOfChildren} = currentParent; const newParent: Product = { ...(childToPromote as ChildProduct), + resultType: ResultType.PRODUCT, children, totalNumberOfChildren, position, From 514984592cb3e300832431aaec0aba5ef414b7fe Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 10:45:07 +0000 Subject: [PATCH 17/41] fix headless-product-listing.test --- .../headless-product-listing.test.ts | 50 +++++++++++++++---- .../product-listing-slice.test.ts | 2 + 2 files changed, 41 insertions(+), 11 deletions(-) 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..bb69f77c70c 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 @@ -31,23 +31,20 @@ import { } from './facets/headless-product-listing-facet-options.js'; import { buildProductListing, - type ProductListing, } from './headless-product-listing.js'; describe('headless product-listing', () => { - let productListing: ProductListing; let engine: MockedCommerceEngine; beforeEach(() => { engine = buildMockCommerceEngine(buildMockCommerceState()); - productListing = buildProductListing(engine); }); afterEach(() => { vi.clearAllMocks(); }); - it('uses sub-controllers', () => { + it('uses sub-controllers with default enableResults=false', () => { const buildProductListingSubControllers = vi.spyOn( SubControllers, 'buildProductListingSubControllers' @@ -57,8 +54,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, @@ -76,6 +73,7 @@ describe('headless product-listing', () => { }); it('adds the correct reducers to engine', () => { + buildProductListing(engine); expect(engine.addReducers).toHaveBeenCalledWith({ productListing: productListingReducer, commerceContext: contextReducer, @@ -90,6 +88,7 @@ describe('headless product-listing', () => { ); const child = {permanentid: 'childPermanentId'} as ChildProduct; + const productListing = buildProductListing(engine); productListing.promoteChildToParent(child); expect(promoteChildToParent).toHaveBeenCalledWith({ @@ -97,25 +96,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/features/commerce/product-listing/product-listing-slice.test.ts b/packages/headless/src/features/commerce/product-listing/product-listing-slice.test.ts index 0f2a20afbae..e7b7381342b 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 @@ -434,6 +434,7 @@ describe('product-listing-slice', () => { }); }); +/* const getProductsFromResults = (results: Result[]): Array => { const products: Array = []; for (const result of results) { @@ -451,3 +452,4 @@ const getBaseProductsFromBaseResults = ( } return products; }; +*/ From f1632191124d8e4bc2c2b1559ad0db384fb3bda8 Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 11:11:32 +0000 Subject: [PATCH 18/41] product listing slice new tests --- .../product-listing-slice.test.ts | 276 ++++++++++++++++-- .../src/test/mock-spotlight-content.ts | 18 ++ 2 files changed, 265 insertions(+), 29 deletions(-) create mode 100644 packages/headless/src/test/mock-spotlight-content.ts 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 e7b7381342b..4b0a1900ff9 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 {BaseProduct, 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,7 @@ import { buildMockProduct, } from '../../../test/mock-product.js'; import {buildFetchProductListingResponse} from '../../../test/mock-product-listing.js'; +import {buildMockSpotlightContent} from '../../../test/mock-spotlight-content.js'; import {setError} from '../../error/error-actions.js'; import {setContext, setView} from '../context/context-actions.js'; import { @@ -18,10 +22,6 @@ import { getProductListingInitialState, type ProductListingState, } from './product-listing-state.js'; -import {ResultType, Result, BaseResult} from '../../../api/commerce/common/result.js'; -import { -Product -} from '../../../api/commerce/common/product.js'; describe('product-listing-slice', () => { let state: ProductListingState; @@ -98,8 +98,7 @@ describe('product-listing-slice', () => { }); it('set #error to null ', () => { - const err = {message: 'message', statusCode: 500, type: 'type'}; - state.error = err; + state.error = {message: 'message', statusCode: 500, type: 'type'}; const response = buildFetchProductListingResponse(); @@ -107,6 +106,79 @@ describe('product-listing-slice', () => { const finalState = productListingReducer(state, action); expect(finalState.error).toBeNull(); }); + + it('updates the results field with products and spotlight content', () => { + const product = buildMockBaseProduct({ec_name: 'product1'}); + const spotlight = buildMockSpotlightContent({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]).toMatchObject({ + ec_name: 'product1', + position: 1, + responseId, + }); + expect(finalState.results[1]).toEqual(spotlight); + }); + + it('sets the #position of each product in results to its 1-based position', () => { + const product1 = buildMockBaseProduct({ec_name: 'product1'}); + const spotlight = buildMockSpotlightContent({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] as Product).position).toBe(11); + expect(finalState.results[1]).toEqual(spotlight); + expect((finalState.results[2] as Product).position).toBe(13); + }); + + it('sets the responseId on each product in results but not on spotlight content', () => { + const product = buildMockBaseProduct({ec_name: 'product1'}); + const spotlight = buildMockSpotlightContent({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); + + expect((finalState.results[0] as Product).responseId).toBe(responseId); + expect(finalState.results[1]).not.toHaveProperty('responseId'); + }); + + 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.products).toHaveLength(1); + expect(finalState.results).toHaveLength(0); + }); }); describe('on #fetchMoreProducts.fulfilled', () => { @@ -203,14 +275,108 @@ describe('product-listing-slice', () => { }); it('set #error to null', () => { - const err = {message: 'message', statusCode: 500, type: 'type'}; - state.error = err; + state.error = {message: 'message', statusCode: 500, type: 'type'}; const response = buildFetchProductListingResponse(); const action = fetchMoreProducts.fulfilled(response, '', {}); const finalState = productListingReducer(state, action); expect(finalState.error).toBeNull(); }); + + it('appends the received results (products and spotlight content) to the results state', () => { + const product1 = buildMockProduct({ + ec_name: 'product1', + responseId: 'old-response-id', + position: 1, + }); + const spotlight1 = buildMockSpotlightContent({name: 'Spotlight 1'}); + state.results = [product1, spotlight1]; + + const product2 = buildMockBaseProduct({ec_name: 'product2'}); + const spotlight2 = buildMockSpotlightContent({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] as Product).ec_name).toBe('product1'); + expect(finalState.results[1]).toEqual(spotlight1); + expect((finalState.results[2] as Product).ec_name).toBe('product2'); + expect(finalState.results[3]).toEqual(spotlight2); + }); + + 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); + }); + + it('sets the responseId on new products in results while preserving existing products responseId', () => { + const product1 = buildMockProduct({ + ec_name: 'product1', + position: 1, + responseId: 'old-response-id', + }); + state.results = [product1]; + + const product2 = buildMockBaseProduct({ec_name: 'product2'}); + const spotlight = buildMockSpotlightContent({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); + + // Original product keeps its responseId + expect((finalState.results[0] as Product).responseId).toBe( + 'old-response-id' + ); + // New product gets the new responseId + expect((finalState.results[1] as Product).responseId).toBe(responseId); + // Spotlight content doesn't have responseId + expect(finalState.results[2]).not.toHaveProperty('responseId'); + }); }); describe('on #fetchProductListing.rejected', () => { @@ -375,7 +541,79 @@ describe('product-listing-slice', () => { }), ]); }); + + 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); + }); + + it('when results contain spotlight content, skips spotlight when searching for parent', () => { + const childProduct = buildMockChildProduct({ + permanentid, + ec_name: 'child name', + }); + + 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]; + + 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'}, @@ -433,23 +671,3 @@ describe('product-listing-slice', () => { }); }); }); - -/* -const getProductsFromResults = (results: Result[]): Array => { - const products: Array = []; - for (const result of results) { - products.push(result.resultType !== ResultType.SPOTLIGHT ? result : null); - } - return products; -}; - -const getBaseProductsFromBaseResults = ( - results: BaseResult[] -): Array => { - const products: Array = []; - for (const result of results) { - products.push(result.resultType !== ResultType.SPOTLIGHT ? result : null); - } - return products; -}; -*/ 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..1e2595a0b26 --- /dev/null +++ b/packages/headless/src/test/mock-spotlight-content.ts @@ -0,0 +1,18 @@ +import { + ResultType, + type SpotlightContent, +} from '../api/commerce/common/result.js'; + +export function buildMockSpotlightContent( + config: Partial = {} +): SpotlightContent { + return { + 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, + }; +} From fb43ff0e4975c313e03765896a219f8bc4238b85 Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 11:24:20 +0000 Subject: [PATCH 19/41] fix product listing selector tests --- .../product-listing-selectors.test.ts | 33 ++++++++++++++++++- .../product-listing/product-listing-slice.ts | 14 ++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) 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-slice.ts b/packages/headless/src/features/commerce/product-listing/product-listing-slice.ts index 1bcbf9f5c83..d17ce2f6992 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 @@ -1,6 +1,10 @@ import {createReducer} from '@reduxjs/toolkit'; import type {CommerceAPIErrorStatusResponse} from '../../../api/commerce/commerce-api-error-response.js'; -import type {BaseProduct, ChildProduct, Product,} from '../../../api/commerce/common/product.js'; +import type { + BaseProduct, + ChildProduct, + Product, +} from '../../../api/commerce/common/product.js'; import {ResultType} from '../../../api/commerce/common/result.js'; import type {ListingCommerceSuccessResponse} from '../../../api/commerce/listing/response.js'; import {setError} from '../../error/error-actions.js'; @@ -11,7 +15,10 @@ import { promoteChildToParent, type QueryCommerceAPIThunkReturn, } from './product-listing-actions.js'; -import {getProductListingInitialState, type ProductListingState,} from './product-listing-state.js'; +import { + getProductListingInitialState, + type ProductListingState, +} from './product-listing-state.js'; export const productListingReducer = createReducer( getProductListingInitialState(), @@ -79,7 +86,8 @@ export const productListingReducer = createReducer( handlePending(state, action.meta.requestId); }) .addCase(promoteChildToParent, (state, action) => { - const productsOrResults = state.results.length > 0 ? state.results : state.products; + const productsOrResults = + state.products.length > 0 ? state.products : state.results; let childToPromote: ChildProduct | undefined; const currentParentIndex = productsOrResults.findIndex((result) => { if (result.resultType === ResultType.SPOTLIGHT) { From a8d5bab2c2a8e1969b64be3377dba23c78ca0adf Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 11:25:27 +0000 Subject: [PATCH 20/41] minor refactor --- .../commerce/product-listing/product-listing-selectors.ts | 2 +- .../features/commerce/product-listing/product-listing-slice.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 e721f7ea921..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 @@ -27,8 +27,8 @@ export const requestIdSelector = (state: CommerceEngineState) => export const numberOfProductsSelector = ( state: Partial ) => - state.productListing?.products.length || state.productListing?.results.length || + state.productListing?.products.length || 0; export const moreProductsAvailableSelector = createSelector( 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 d17ce2f6992..c683321c0ec 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 @@ -87,7 +87,7 @@ export const productListingReducer = createReducer( }) .addCase(promoteChildToParent, (state, action) => { const productsOrResults = - state.products.length > 0 ? state.products : state.results; + state.results.length > 0 ? state.results : state.products; let childToPromote: ChildProduct | undefined; const currentParentIndex = productsOrResults.findIndex((result) => { if (result.resultType === ResultType.SPOTLIGHT) { From 86afe245ad045eaa45f0587176b3ca79b20ccd8d Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 11:38:02 +0000 Subject: [PATCH 21/41] respond to ci --- .../commerce/product-listing/headless-product-listing.test.ts | 4 +--- .../commerce/product-listing/headless-product-listing.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) 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 bb69f77c70c..2b73742f79c 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,9 +29,7 @@ import { facetResponseSelector, isFacetLoadingResponseSelector, } from './facets/headless-product-listing-facet-options.js'; -import { - buildProductListing, -} from './headless-product-listing.js'; +import {buildProductListing} from './headless-product-listing.js'; describe('headless product-listing', () => { let engine: MockedCommerceEngine; 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 a58f0287f3e..cf54d2feafc 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 @@ -103,7 +103,7 @@ export interface ProductListingState { responseId: string; } -export interface ProductListingOptions extends FetchProductListingPayload {} +interface ProductListingOptions extends FetchProductListingPayload {} /** * Creates a `ProductListing` controller instance. From 9738dc25daf73718d2ea921d562bf9238c67bc3d Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 12:44:48 +0000 Subject: [PATCH 22/41] remove unused field --- .../app/commerce-engine/commerce-engine-configuration.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/headless/src/app/commerce-engine/commerce-engine-configuration.ts b/packages/headless/src/app/commerce-engine/commerce-engine-configuration.ts index 303487dc349..c8923076f86 100644 --- a/packages/headless/src/app/commerce-engine/commerce-engine-configuration.ts +++ b/packages/headless/src/app/commerce-engine/commerce-engine-configuration.ts @@ -39,12 +39,6 @@ export interface CommerceEngineConfiguration extends EngineConfiguration { * See [Headless proxy: Commerce](https://docs.coveo.com/en/headless/latest/usage/proxy#commerce). **/ proxyBaseUrl?: string; - /** - * When set to true, fills the `results` field rather than the `products` field - * in the controller state. It may also include Spotlight Content in the results. - * @default false - */ - enableResults?: boolean; } export const commerceEngineConfigurationSchema = From 2913fd960a2aff2a9f2b34a8526d08a8cc2a7a52 Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 13:22:48 +0000 Subject: [PATCH 23/41] respond to a comment --- .../commerce/product-listing/headless-product-listing.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 cf54d2feafc..32aba3357dc 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 @@ -103,7 +103,12 @@ export interface ProductListingState { responseId: string; } -interface ProductListingOptions extends FetchProductListingPayload {} +/** + * Options for configuring the `ProductListing` controller. + * @group Buildable controllers + * @category ProductListing + */ +export interface ProductListingOptions extends FetchProductListingPayload {} /** * Creates a `ProductListing` controller instance. From 531092a9f1ac56a218e59ff114f89ac488d3f439 Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 13:25:16 +0000 Subject: [PATCH 24/41] fix export --- packages/headless/src/commerce.index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/headless/src/commerce.index.ts b/packages/headless/src/commerce.index.ts index 29f88601c32..8cb0dd3200b 100644 --- a/packages/headless/src/commerce.index.ts +++ b/packages/headless/src/commerce.index.ts @@ -238,6 +238,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'; From 4053b0c2a09031cd75081e9ae600ff4ec7bc3b68 Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 17:02:04 +0000 Subject: [PATCH 25/41] put position on spotlight --- .../src/api/commerce/common/product.ts | 11 +---- .../src/api/commerce/common/result.ts | 17 ++++++- .../product-listing-slice.test.ts | 48 +++++++++++-------- .../product-listing/product-listing-slice.ts | 20 ++++++-- .../src/test/mock-spotlight-content.ts | 23 ++++++--- 5 files changed, 80 insertions(+), 39 deletions(-) diff --git a/packages/headless/src/api/commerce/common/product.ts b/packages/headless/src/api/commerce/common/product.ts index e16676ceed9..833e3a675af 100644 --- a/packages/headless/src/api/commerce/common/product.ts +++ b/packages/headless/src/api/commerce/common/product.ts @@ -1,5 +1,5 @@ import type {HighlightKeyword} from '../../../utils/highlight.js'; -import type {ResultType} from './result.js'; +import type {ResultPosition, ResultType} from './result.js'; export type ChildProduct = Omit< BaseProduct, @@ -150,11 +150,4 @@ export interface BaseProduct { resultType: ResultType.PRODUCT | ResultType.CHILD_PRODUCT; } -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). - */ - position: number; -} +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 index 02b89c0aebb..04d79ca35d9 100644 --- a/packages/headless/src/api/commerce/common/result.ts +++ b/packages/headless/src/api/commerce/common/result.ts @@ -6,7 +6,7 @@ export enum ResultType { SPOTLIGHT = 'spotlight', } -export interface SpotlightContent { +export interface BaseSpotlightContent { /** * The URI to navigate to when the spotlight content is clicked. */ @@ -33,5 +33,18 @@ export interface SpotlightContent { resultType: ResultType.SPOTLIGHT; } -export type BaseResult = BaseProduct | SpotlightContent; +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/features/commerce/product-listing/product-listing-slice.test.ts b/packages/headless/src/features/commerce/product-listing/product-listing-slice.test.ts index 4b0a1900ff9..92db032ca58 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 @@ -9,7 +9,10 @@ import { buildMockProduct, } from '../../../test/mock-product.js'; import {buildFetchProductListingResponse} from '../../../test/mock-product-listing.js'; -import {buildMockSpotlightContent} from '../../../test/mock-spotlight-content.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 { @@ -109,7 +112,7 @@ describe('product-listing-slice', () => { it('updates the results field with products and spotlight content', () => { const product = buildMockBaseProduct({ec_name: 'product1'}); - const spotlight = buildMockSpotlightContent({name: 'Spotlight 1'}); + const spotlight = buildMockBaseSpotlightContent({name: 'Spotlight 1'}); const responseId = 'some-response-id'; const response = buildFetchProductListingResponse({ results: [product, spotlight], @@ -120,17 +123,17 @@ describe('product-listing-slice', () => { const finalState = productListingReducer(state, action); expect(finalState.results).toHaveLength(2); - expect(finalState.results[0]).toMatchObject({ - ec_name: 'product1', - position: 1, - responseId, - }); - expect(finalState.results[1]).toEqual(spotlight); + expect(finalState.results[0]).toEqual( + buildMockProduct({ec_name: 'product1', position: 1, responseId}) + ); + expect(finalState.results[1]).toEqual( + buildMockSpotlightContent({name: 'Spotlight 1', position: 2}) + ); }); it('sets the #position of each product in results to its 1-based position', () => { const product1 = buildMockBaseProduct({ec_name: 'product1'}); - const spotlight = buildMockSpotlightContent({name: 'Spotlight 1'}); + const spotlight = buildMockBaseSpotlightContent({name: 'Spotlight 1'}); const product2 = buildMockBaseProduct({ec_name: 'product2'}); const response = buildFetchProductListingResponse({ results: [product1, spotlight, product2], @@ -145,14 +148,14 @@ describe('product-listing-slice', () => { const action = fetchProductListing.fulfilled(response, '', {}); const finalState = productListingReducer(state, action); - expect((finalState.results[0] as Product).position).toBe(11); - expect(finalState.results[1]).toEqual(spotlight); - expect((finalState.results[2] as Product).position).toBe(13); + expect(finalState.results[0].position).toBe(11); + expect(finalState.results[1].position).toBe(12); + expect(finalState.results[2].position).toBe(13); }); it('sets the responseId on each product in results but not on spotlight content', () => { const product = buildMockBaseProduct({ec_name: 'product1'}); - const spotlight = buildMockSpotlightContent({name: 'Spotlight 1'}); + const spotlight = buildMockBaseSpotlightContent({name: 'Spotlight 1'}); const responseId = 'test-response-id'; const response = buildFetchProductListingResponse({ results: [product, spotlight], @@ -289,11 +292,14 @@ describe('product-listing-slice', () => { responseId: 'old-response-id', position: 1, }); - const spotlight1 = buildMockSpotlightContent({name: 'Spotlight 1'}); + const spotlight1 = buildMockSpotlightContent({ + name: 'Spotlight 1', + position: 2, + }); state.results = [product1, spotlight1]; const product2 = buildMockBaseProduct({ec_name: 'product2'}); - const spotlight2 = buildMockSpotlightContent({name: 'Spotlight 2'}); + const spotlight2 = buildMockBaseSpotlightContent({name: 'Spotlight 2'}); const responseId = 'new-response-id'; const response = buildFetchProductListingResponse({ results: [product2, spotlight2], @@ -310,10 +316,14 @@ describe('product-listing-slice', () => { const finalState = productListingReducer(state, action); expect(finalState.results).toHaveLength(4); - expect((finalState.results[0] as Product).ec_name).toBe('product1'); + expect(finalState.results[0]).toEqual(product1); expect(finalState.results[1]).toEqual(spotlight1); - expect((finalState.results[2] as Product).ec_name).toBe('product2'); - expect(finalState.results[3]).toEqual(spotlight2); + expect(finalState.results[2]).toEqual( + buildMockProduct({ec_name: 'product2', position: 3, responseId}) + ); + expect(finalState.results[3]).toEqual( + buildMockSpotlightContent({name: 'Spotlight 2', position: 4}) + ); }); it('sets the #position of each product in results to its 1-based position in the unpaginated list', () => { @@ -352,7 +362,7 @@ describe('product-listing-slice', () => { state.results = [product1]; const product2 = buildMockBaseProduct({ec_name: 'product2'}); - const spotlight = buildMockSpotlightContent({name: 'Spotlight 1'}); + const spotlight = buildMockBaseSpotlightContent({name: 'Spotlight 1'}); const responseId = 'new-response-id'; const response = buildFetchProductListingResponse({ results: [product2, spotlight], 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 c683321c0ec..1a4b71ea9a6 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,6 +5,10 @@ import type { ChildProduct, Product, } from '../../../api/commerce/common/product.js'; +import type { + BaseSpotlightContent, + SpotlightContent, +} from '../../../api/commerce/common/result.js'; import {ResultType} from '../../../api/commerce/common/result.js'; import type {ListingCommerceSuccessResponse} from '../../../api/commerce/listing/response.js'; import {setError} from '../../error/error-actions.js'; @@ -44,7 +48,7 @@ export const productListingReducer = createReducer( ); state.results = action.payload.response.results.map((result, index) => result.resultType === ResultType.SPOTLIGHT - ? result + ? preprocessSpotlightContent(result, paginationOffset + index + 1) : preprocessProduct( result, paginationOffset + index + 1, @@ -70,11 +74,11 @@ export const productListingReducer = createReducer( state.results = state.results.concat( action.payload.response.results.map((result, index) => result.resultType === ResultType.SPOTLIGHT - ? result + ? preprocessSpotlightContent(result, paginationOffset + index + 1) : preprocessProduct( result, paginationOffset + index + 1, - action.payload!.response.responseId + action.payload?.response.responseId ) ) ); @@ -180,3 +184,13 @@ function preprocessProduct( responseId, }; } + +function preprocessSpotlightContent( + spotlight: BaseSpotlightContent, + position: number +): SpotlightContent { + return { + ...spotlight, + position, + }; +} diff --git a/packages/headless/src/test/mock-spotlight-content.ts b/packages/headless/src/test/mock-spotlight-content.ts index 1e2595a0b26..360f9b94f0e 100644 --- a/packages/headless/src/test/mock-spotlight-content.ts +++ b/packages/headless/src/test/mock-spotlight-content.ts @@ -1,11 +1,12 @@ -import { - ResultType, - type SpotlightContent, +import type { + BaseSpotlightContent, + SpotlightContent, } from '../api/commerce/common/result.js'; +import {ResultType} from '../api/commerce/common/result.js'; -export function buildMockSpotlightContent( - config: Partial = {} -): SpotlightContent { +export function buildMockBaseSpotlightContent( + config: Partial = {} +): BaseSpotlightContent { return { clickUri: 'https://example.com/spotlight', desktopImage: 'https://example.com/desktop.jpg', @@ -16,3 +17,13 @@ export function buildMockSpotlightContent( ...config, }; } + +export function buildMockSpotlightContent( + config: Partial = {} +): SpotlightContent { + const {position, ...baseSpotlightContentConfig} = config; + return { + ...buildMockBaseSpotlightContent(baseSpotlightContentConfig), + position: position ?? 1, + }; +} From 750212bf588af284d1fb7e80897fcbc5aaa16d0b Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 17:27:32 +0000 Subject: [PATCH 26/41] put responseId on result too --- .../src/api/commerce/common/result.ts | 4 ++ .../product-listing-slice.test.ts | 44 ++++++++++++------- .../product-listing/product-listing-slice.ts | 16 +++++-- 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/packages/headless/src/api/commerce/common/result.ts b/packages/headless/src/api/commerce/common/result.ts index 04d79ca35d9..c3db973bccb 100644 --- a/packages/headless/src/api/commerce/common/result.ts +++ b/packages/headless/src/api/commerce/common/result.ts @@ -27,6 +27,10 @@ export interface BaseSpotlightContent { * 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. */ 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 92db032ca58..503222b048d 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 @@ -127,11 +127,15 @@ describe('product-listing-slice', () => { buildMockProduct({ec_name: 'product1', position: 1, responseId}) ); expect(finalState.results[1]).toEqual( - buildMockSpotlightContent({name: 'Spotlight 1', position: 2}) + buildMockSpotlightContent({ + name: 'Spotlight 1', + position: 2, + responseId, + }) ); }); - it('sets the #position of each product in results to its 1-based position', () => { + 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'}); @@ -153,7 +157,7 @@ describe('product-listing-slice', () => { expect(finalState.results[2].position).toBe(13); }); - it('sets the responseId on each product in results but not on spotlight content', () => { + 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'; @@ -165,8 +169,8 @@ describe('product-listing-slice', () => { const action = fetchProductListing.fulfilled(response, '', {}); const finalState = productListingReducer(state, action); - expect((finalState.results[0] as Product).responseId).toBe(responseId); - expect(finalState.results[1]).not.toHaveProperty('responseId'); + expect(finalState.results[0].responseId).toBe(responseId); + expect(finalState.results[1].responseId).toBe(responseId); }); it('keeps results empty when response.results is empty', () => { @@ -322,7 +326,11 @@ describe('product-listing-slice', () => { buildMockProduct({ec_name: 'product2', position: 3, responseId}) ); expect(finalState.results[3]).toEqual( - buildMockSpotlightContent({name: 'Spotlight 2', position: 4}) + buildMockSpotlightContent({ + name: 'Spotlight 2', + position: 4, + responseId, + }) ); }); @@ -353,13 +361,19 @@ describe('product-listing-slice', () => { expect((finalState.results[2] as Product).position).toBe(3); }); - it('sets the responseId on new products in results while preserving existing products responseId', () => { + 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: 'old-response-id', + responseId: oldResponseId, }); - state.results = [product1]; + const spotlight1 = buildMockSpotlightContent({ + name: 'spotlight1', + position: 1, + responseId: oldResponseId, + }); + state.results = [product1, spotlight1]; const product2 = buildMockBaseProduct({ec_name: 'product2'}); const spotlight = buildMockBaseSpotlightContent({name: 'Spotlight 1'}); @@ -378,14 +392,10 @@ describe('product-listing-slice', () => { const action = fetchMoreProducts.fulfilled(response, '', {}); const finalState = productListingReducer(state, action); - // Original product keeps its responseId - expect((finalState.results[0] as Product).responseId).toBe( - 'old-response-id' - ); - // New product gets the new responseId - expect((finalState.results[1] as Product).responseId).toBe(responseId); - // Spotlight content doesn't have responseId - expect(finalState.results[2]).not.toHaveProperty('responseId'); + 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); }); }); 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 1a4b71ea9a6..371ee65a159 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 @@ -48,7 +48,11 @@ export const productListingReducer = createReducer( ); state.results = action.payload.response.results.map((result, index) => result.resultType === ResultType.SPOTLIGHT - ? preprocessSpotlightContent(result, paginationOffset + index + 1) + ? preprocessSpotlightContent( + result, + paginationOffset + index + 1, + action.payload.response.responseId + ) : preprocessProduct( result, paginationOffset + index + 1, @@ -74,7 +78,11 @@ export const productListingReducer = createReducer( state.results = state.results.concat( action.payload.response.results.map((result, index) => result.resultType === ResultType.SPOTLIGHT - ? preprocessSpotlightContent(result, paginationOffset + index + 1) + ? preprocessSpotlightContent( + result, + paginationOffset + index + 1, + action.payload?.response.responseId + ) : preprocessProduct( result, paginationOffset + index + 1, @@ -187,10 +195,12 @@ function preprocessProduct( function preprocessSpotlightContent( spotlight: BaseSpotlightContent, - position: number + position: number, + responseId?: string ): SpotlightContent { return { ...spotlight, position, + responseId, }; } From a36065d349861914e5187bc40ec46f21a5b09439 Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 17:30:11 +0000 Subject: [PATCH 27/41] fix test --- .../commerce/product-listing/product-listing-slice.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 503222b048d..f102e521007 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 @@ -369,7 +369,7 @@ describe('product-listing-slice', () => { responseId: oldResponseId, }); const spotlight1 = buildMockSpotlightContent({ - name: 'spotlight1', + name: 'Spotlight 1', position: 1, responseId: oldResponseId, }); @@ -392,6 +392,7 @@ describe('product-listing-slice', () => { 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); From 3c28bd6033f40c886c8d57b8d9538e516d8914e5 Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 17:40:41 +0000 Subject: [PATCH 28/41] rearrange tests a bit --- .../product-listing-slice.test.ts | 854 +++++++++--------- .../product-listing/product-listing-slice.ts | 36 +- 2 files changed, 451 insertions(+), 439 deletions(-) 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 f102e521007..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 @@ -38,365 +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 action = fetchProductListing.fulfilled(response, '', {}); - const finalState = productListingReducer(state, action); + ]; + const responseId = 'some-response-id'; + const response = buildFetchProductListingResponse({ + products, + responseId, + }); - expect(finalState.products[0].position).toBe(21); - expect(finalState.products[1].position).toBe(22); - }); + const action = fetchProductListing.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); - 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, + 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].responseId).toBe(responseId); - expect(finalState.products[1].responseId).toBe(responseId); - }); + const response = buildFetchProductListingResponse(); - it('set #error to null ', () => { - state.error = {message: 'message', statusCode: 500, type: 'type'}; - - const response = buildFetchProductListingResponse(); - - const action = fetchProductListing.fulfilled(response, '', {}); - const finalState = productListingReducer(state, action); - expect(finalState.error).toBeNull(); - }); - - 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.error).toBeNull(); }); + }); - 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, + 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, + }) + ); + }); - 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, - }, + 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); }); - const action = fetchProductListing.fulfilled(response, '', {}); - const finalState = productListingReducer(state, action); + 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, + }); - expect(finalState.results[0].position).toBe(11); - expect(finalState.results[1].position).toBe(12); - expect(finalState.results[2].position).toBe(13); - }); + const action = fetchProductListing.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); - 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, + expect(finalState.results[0].responseId).toBe(responseId); + expect(finalState.results[1].responseId).toBe(responseId); }); - const action = fetchProductListing.fulfilled(response, '', {}); - const finalState = productListingReducer(state, action); + it('keeps results empty when response.results is empty', () => { + const product = buildMockBaseProduct({ec_name: 'product1'}); + const response = buildFetchProductListingResponse({ + products: [product], + results: [], + }); - expect(finalState.results[0].responseId).toBe(responseId); - expect(finalState.results[1].responseId).toBe(responseId); - }); + const action = fetchProductListing.fulfilled(response, '', {}); + const finalState = productListingReducer(state, action); - it('keeps results empty when response.results is empty', () => { - const product = buildMockBaseProduct({ec_name: 'product1'}); - const response = buildFetchProductListingResponse({ - products: [product], - results: [], + expect(finalState.products).toHaveLength(1); + expect(finalState.results).toHaveLength(0); }); - - 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', () => { - state.error = {message: 'message', statusCode: 500, type: 'type'}; - - const response = buildFetchProductListingResponse(); - const action = fetchMoreProducts.fulfilled(response, '', {}); - const finalState = productListingReducer(state, action); - expect(finalState.error).toBeNull(); - }); - - it('appends the received results (products and spotlight content) to the results state', () => { - const product1 = buildMockProduct({ - ec_name: 'product1', - responseId: 'old-response-id', - position: 1, - }); - const spotlight1 = buildMockSpotlightContent({ - name: 'Spotlight 1', - position: 2, - }); - 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, - }, + 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 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, + 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, - }) - ); - }); - - 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); - }); - - 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, - }, + 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); }); - - 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); }); }); @@ -519,119 +533,125 @@ 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', - }); - - 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, - }), - ]); }); - 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, + 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); }); - const parentProduct = buildMockProduct({ - permanentid: parentPermanentId, - children: [childProduct], - totalNumberOfChildren: 1, - position: 3, - responseId: 'test-response-id', - }); + it('when results contain spotlight content, skips spotlight when searching for parent', () => { + const childProduct = buildMockChildProduct({ + permanentid, + ec_name: 'child name', + }); - const spotlight = buildMockSpotlightContent({name: 'Spotlight 1'}); - state.results = [parentProduct, spotlight]; + const parentProduct = buildMockProduct({ + permanentid: parentPermanentId, + children: [childProduct], + totalNumberOfChildren: 1, + position: 2, + responseId: 'test-response-id', + }); - const finalState = productListingReducer(state, action); + const spotlight = buildMockSpotlightContent({name: 'Spotlight 1'}); + state.results = [spotlight, parentProduct]; - 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); - }); + const finalState = productListingReducer(state, action); - it('when results contain spotlight content, skips spotlight when searching for parent', () => { - const childProduct = buildMockChildProduct({ - permanentid, - ec_name: 'child name', + expect(finalState.results).toHaveLength(2); + expect(finalState.results[0]).toEqual(spotlight); + expect((finalState.results[1] as Product).permanentid).toBe( + permanentid + ); }); - - 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]; - - 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); }); }); 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 371ee65a159..aca5ef0261e 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 @@ -47,17 +47,13 @@ export const productListingReducer = createReducer( ) ); state.results = action.payload.response.results.map((result, index) => - result.resultType === ResultType.SPOTLIGHT - ? preprocessSpotlightContent( - result, - paginationOffset + index + 1, - action.payload.response.responseId - ) - : preprocessProduct( - result, - paginationOffset + index + 1, - action.payload.response.responseId - ) + (result.resultType === ResultType.SPOTLIGHT + ? preprocessSpotlightContent + : preprocessProduct)( + result, + paginationOffset + index + 1, + action.payload.response.responseId + ) ); }) .addCase(fetchMoreProducts.fulfilled, (state, action) => { @@ -77,17 +73,13 @@ export const productListingReducer = createReducer( ); state.results = state.results.concat( action.payload.response.results.map((result, index) => - result.resultType === ResultType.SPOTLIGHT - ? preprocessSpotlightContent( - result, - paginationOffset + index + 1, - action.payload?.response.responseId - ) - : preprocessProduct( - result, - paginationOffset + index + 1, - action.payload?.response.responseId - ) + (result.resultType === ResultType.SPOTLIGHT + ? preprocessSpotlightContent + : preprocessProduct)( + result, + paginationOffset + index + 1, + action.payload?.response.responseId + ) ) ); }) From aa8d27e1a506039fa4bcdbaf87cf90868355d071 Mon Sep 17 00:00:00 2001 From: Tooni Date: Wed, 26 Nov 2025 17:41:15 +0000 Subject: [PATCH 29/41] fix type error --- .../product-listing/product-listing-slice.ts | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) 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 aca5ef0261e..371ee65a159 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 @@ -47,13 +47,17 @@ export const productListingReducer = createReducer( ) ); state.results = action.payload.response.results.map((result, index) => - (result.resultType === ResultType.SPOTLIGHT - ? preprocessSpotlightContent - : preprocessProduct)( - result, - paginationOffset + index + 1, - action.payload.response.responseId - ) + result.resultType === ResultType.SPOTLIGHT + ? preprocessSpotlightContent( + result, + paginationOffset + index + 1, + action.payload.response.responseId + ) + : preprocessProduct( + result, + paginationOffset + index + 1, + action.payload.response.responseId + ) ); }) .addCase(fetchMoreProducts.fulfilled, (state, action) => { @@ -73,13 +77,17 @@ export const productListingReducer = createReducer( ); state.results = state.results.concat( action.payload.response.results.map((result, index) => - (result.resultType === ResultType.SPOTLIGHT - ? preprocessSpotlightContent - : preprocessProduct)( - result, - paginationOffset + index + 1, - action.payload?.response.responseId - ) + result.resultType === ResultType.SPOTLIGHT + ? preprocessSpotlightContent( + result, + paginationOffset + index + 1, + action.payload?.response.responseId + ) + : preprocessProduct( + result, + paginationOffset + index + 1, + action.payload?.response.responseId + ) ) ); }) From 4fc3b4294050ae64131d39cfffd58f68749ffa58 Mon Sep 17 00:00:00 2001 From: Tooni Date: Thu, 27 Nov 2025 10:15:55 +0000 Subject: [PATCH 30/41] make minor improvement to typing --- .../product-listing/product-listing-slice.ts | 51 ++++++++++--------- 1 file changed, 27 insertions(+), 24 deletions(-) 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 371ee65a159..4bfacb8e52f 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 @@ -7,6 +7,7 @@ import type { } from '../../../api/commerce/common/product.js'; import type { BaseSpotlightContent, + Result, SpotlightContent, } from '../../../api/commerce/common/result.js'; import {ResultType} from '../../../api/commerce/common/result.js'; @@ -46,18 +47,19 @@ export const productListingReducer = createReducer( action.payload.response.responseId ) ); - state.results = action.payload.response.results.map((result, index) => - result.resultType === ResultType.SPOTLIGHT - ? preprocessSpotlightContent( - result, - paginationOffset + index + 1, - action.payload.response.responseId - ) - : preprocessProduct( - result, - paginationOffset + index + 1, - action.payload.response.responseId - ) + state.results = action.payload.response.results.map( + (result, index): Result => + result.resultType === ResultType.SPOTLIGHT + ? preprocessSpotlightContent( + result, + paginationOffset + index + 1, + action.payload.response.responseId + ) + : preprocessProduct( + result, + paginationOffset + index + 1, + action.payload.response.responseId + ) ); }) .addCase(fetchMoreProducts.fulfilled, (state, action) => { @@ -76,18 +78,19 @@ export const productListingReducer = createReducer( ) ); state.results = state.results.concat( - action.payload.response.results.map((result, index) => - result.resultType === ResultType.SPOTLIGHT - ? preprocessSpotlightContent( - result, - paginationOffset + index + 1, - action.payload?.response.responseId - ) - : preprocessProduct( - result, - paginationOffset + index + 1, - action.payload?.response.responseId - ) + action.payload.response.results.map( + (result, index): Result => + result.resultType === ResultType.SPOTLIGHT + ? preprocessSpotlightContent( + result, + paginationOffset + index + 1, + action.payload?.response.responseId + ) + : preprocessProduct( + result, + paginationOffset + index + 1, + action.payload?.response.responseId + ) ) ); }) From 8fa97f6d6c1cc051ed8063412dc43db713c771d1 Mon Sep 17 00:00:00 2001 From: Tooni Date: Fri, 28 Nov 2025 10:21:17 +0000 Subject: [PATCH 31/41] respond to @alexprudhomme's feedback --- .../commerce/product-listing/headless-product-listing.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 32aba3357dc..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,8 +17,8 @@ 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 { - type FetchProductListingPayload, fetchMoreProducts, fetchProductListing, promoteChildToParent, @@ -98,6 +99,7 @@ export interface ProductListing */ export interface ProductListingState { products: Product[]; + results: Result[]; error: CommerceAPIErrorStatusResponse | null; isLoading: boolean; responseId: string; @@ -158,10 +160,11 @@ export function buildProductListing( ...subControllers, get state() { - const {products, error, isLoading, responseId} = + const {products, results, error, isLoading, responseId} = getState().productListing; return { products, + results, error, isLoading, responseId, From 4bac225ee0d066b63b9b7433fcf80f115e86a992 Mon Sep 17 00:00:00 2001 From: Tooni Date: Fri, 28 Nov 2025 10:24:18 +0000 Subject: [PATCH 32/41] rearrange some imports --- .../product-listing/product-listing-actions-loader.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 5828f9693be..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,14 +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 { - type FetchProductListingPayload, fetchMoreProducts, fetchProductListing, - type PromoteChildToParentPayload, promoteChildToParent, - type QueryCommerceAPIThunkReturn, - type StateNeededByFetchProductListing, } from './product-listing-actions.js'; /** From 186ea12a09bc59b381e413ade852f97e3bc582e5 Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 2 Dec 2025 09:56:25 +0000 Subject: [PATCH 33/41] refactor: merge preprocessing functions --- .../product-listing/product-listing-slice.ts | 53 +++++++++---------- 1 file changed, 24 insertions(+), 29 deletions(-) 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 4bfacb8e52f..181c062cf8d 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 @@ -6,6 +6,7 @@ import type { Product, } from '../../../api/commerce/common/product.js'; import type { + BaseResult, BaseSpotlightContent, Result, SpotlightContent, @@ -47,19 +48,12 @@ export const productListingReducer = createReducer( action.payload.response.responseId ) ); - state.results = action.payload.response.results.map( - (result, index): Result => - result.resultType === ResultType.SPOTLIGHT - ? preprocessSpotlightContent( - result, - paginationOffset + index + 1, - action.payload.response.responseId - ) - : preprocessProduct( - result, - paginationOffset + index + 1, - action.payload.response.responseId - ) + state.results = action.payload.response.results.map((result, index) => + preprocessResult( + result, + paginationOffset + index + 1, + action.payload.response.responseId + ) ); }) .addCase(fetchMoreProducts.fulfilled, (state, action) => { @@ -78,25 +72,15 @@ export const productListingReducer = createReducer( ) ); state.results = state.results.concat( - action.payload.response.results.map( - (result, index): Result => - result.resultType === ResultType.SPOTLIGHT - ? preprocessSpotlightContent( - result, - paginationOffset + index + 1, - action.payload?.response.responseId - ) - : preprocessProduct( - result, - paginationOffset + index + 1, - action.payload?.response.responseId - ) + action.payload.response.results.map((result, index) => + preprocessResult( + result, + paginationOffset + index + 1, + action.payload?.response.responseId + ) ) ); }) - .addCase(fetchProductListing.pending, (state, action) => { - handlePending(state, action.meta.requestId); - }) .addCase(fetchMoreProducts.pending, (state, action) => { handlePending(state, action.meta.requestId); }) @@ -170,6 +154,17 @@ function getPaginationOffset(payload: QueryCommerceAPIThunkReturn): number { return pagination.page * pagination.perPage; } +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, From c57d67c25caa00a5076c9e0bab879649d54f7337 Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 2 Dec 2025 09:59:57 +0000 Subject: [PATCH 34/41] refactor: deduplicate some code for processing products/results --- .../product-listing/product-listing-slice.ts | 61 +++++++++++-------- 1 file changed, 36 insertions(+), 25 deletions(-) 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 181c062cf8d..176cd747799 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 @@ -40,20 +40,15 @@ export const productListingReducer = createReducer( .addCase(fetchProductListing.fulfilled, (state, action) => { const paginationOffset = getPaginationOffset(action.payload); handleFulfilled(state, action.payload.response); - state.products = action.payload.response.products.map( - (product, index) => - preprocessProduct( - product, - paginationOffset + index + 1, - action.payload.response.responseId - ) + state.products = mapPreprocessedProducts( + action.payload.response.products, + paginationOffset, + action.payload.response.responseId ); - state.results = action.payload.response.results.map((result, index) => - preprocessResult( - result, - paginationOffset + index + 1, - action.payload.response.responseId - ) + state.results = mapPreprocessedResults( + action.payload.response.results, + paginationOffset, + action.payload.response.responseId ); }) .addCase(fetchMoreProducts.fulfilled, (state, action) => { @@ -63,21 +58,17 @@ export const productListingReducer = createReducer( const paginationOffset = getPaginationOffset(action.payload); 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( - action.payload.response.results.map((result, index) => - preprocessResult( - result, - paginationOffset + index + 1, - action.payload?.response.responseId - ) + mapPreprocessedResults( + action.payload.response.results, + paginationOffset, + action.payload.response.responseId ) ); }) @@ -154,6 +145,26 @@ 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, From 72b9f0723852775c2631258266162a0a634d9114 Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 2 Dec 2025 10:06:23 +0000 Subject: [PATCH 35/41] avoid type cast --- .../product-listing/product-listing-slice.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) 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 176cd747799..a95f4697f8c 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,13 +5,13 @@ import type { ChildProduct, Product, } from '../../../api/commerce/common/product.js'; -import type { - BaseResult, - BaseSpotlightContent, - Result, - SpotlightContent, +import { + type BaseResult, + type BaseSpotlightContent, + type Result, + ResultType, + type SpotlightContent, } from '../../../api/commerce/common/result.js'; -import {ResultType} 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'; @@ -89,11 +89,16 @@ export const productListingReducer = createReducer( return !!childToPromote; }); - if (currentParentIndex === -1 || childToPromote === undefined) { + if ( + currentParentIndex === -1 || + childToPromote === undefined || + productsOrResults[currentParentIndex].resultType === + ResultType.SPOTLIGHT + ) { return; } - const currentParent = productsOrResults[currentParentIndex] as Product; + const currentParent = productsOrResults[currentParentIndex]; const responseId = currentParent.responseId; const position = currentParent.position; const {children, totalNumberOfChildren} = currentParent; From fc1ea6cbc3263913c9940301274ca9a5c770a5ce Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 2 Dec 2025 10:07:08 +0000 Subject: [PATCH 36/41] reorder --- .../commerce/product-listing/product-listing-slice.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 a95f4697f8c..61f10a7d941 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 @@ -89,16 +89,15 @@ export const productListingReducer = createReducer( return !!childToPromote; }); + const currentParent = productsOrResults[currentParentIndex]; if ( currentParentIndex === -1 || childToPromote === undefined || - productsOrResults[currentParentIndex].resultType === - ResultType.SPOTLIGHT + currentParent.resultType === ResultType.SPOTLIGHT ) { return; } - const currentParent = productsOrResults[currentParentIndex]; const responseId = currentParent.responseId; const position = currentParent.position; const {children, totalNumberOfChildren} = currentParent; From bb9b549a1093abf54192087bf98ed05fcba41038 Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 2 Dec 2025 10:20:19 +0000 Subject: [PATCH 37/41] test: refactor tests to check enableResults param with mocks --- .../headless-product-listing.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) 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 2b73742f79c..0c258322b3a 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 @@ -47,6 +47,14 @@ describe('headless product-listing', () => { SubControllers, 'buildProductListingSubControllers' ); + const fetchProductListingMock = vi.spyOn( + ProductListingActions, + 'fetchProductListing' + ); + const fetchMoreProductsMock = vi.spyOn( + ProductListingActions, + 'fetchMoreProducts' + ); buildProductListing(engine); @@ -68,6 +76,39 @@ describe('headless product-listing', () => { totalEntriesSelector: totalEntriesPrincipalSelector, numberOfProductsSelector, }); + + const callArgs = buildProductListingSubControllers.mock.calls[0][1]; + callArgs.fetchProductsActionCreator(); + expect(fetchProductListingMock).toHaveBeenCalledWith({ + enableResults: false, + }); + + callArgs.fetchMoreProductsActionCreator(); + expect(fetchMoreProductsMock).toHaveBeenCalledWith({enableResults: false}); + }); + + it('uses sub-controllers with enableResults=true when specified', () => { + 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', () => { From d2d8020dc301b33fe24dd6a07fa4802219a52911 Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 2 Dec 2025 10:22:59 +0000 Subject: [PATCH 38/41] test: rearrange tests a bit --- .../headless-product-listing.test.ts | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) 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 0c258322b3a..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 @@ -42,19 +42,11 @@ describe('headless product-listing', () => { vi.clearAllMocks(); }); - it('uses sub-controllers with default enableResults=false', () => { + it('uses sub-controllers', () => { const buildProductListingSubControllers = vi.spyOn( SubControllers, 'buildProductListingSubControllers' ); - const fetchProductListingMock = vi.spyOn( - ProductListingActions, - 'fetchProductListing' - ); - const fetchMoreProductsMock = vi.spyOn( - ProductListingActions, - 'fetchMoreProducts' - ); buildProductListing(engine); @@ -76,6 +68,23 @@ describe('headless product-listing', () => { totalEntriesSelector: totalEntriesPrincipalSelector, numberOfProductsSelector, }); + }); + + 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(); @@ -87,7 +96,7 @@ describe('headless product-listing', () => { expect(fetchMoreProductsMock).toHaveBeenCalledWith({enableResults: false}); }); - it('uses sub-controllers with enableResults=true when specified', () => { + it('creates closures for fetching products that capture enableResults=true', () => { const buildProductListingSubControllers = vi.spyOn( SubControllers, 'buildProductListingSubControllers' From 66c46e035255758f1b66fb9b06384c0f1227d691 Mon Sep 17 00:00:00 2001 From: Tooni Date: Tue, 2 Dec 2025 11:02:04 +0000 Subject: [PATCH 39/41] restore code that ai randomly deleted --- .../features/commerce/product-listing/product-listing-slice.ts | 3 +++ 1 file changed, 3 insertions(+) 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 61f10a7d941..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 @@ -72,6 +72,9 @@ export const productListingReducer = createReducer( ) ); }) + .addCase(fetchProductListing.pending, (state, action) => { + handlePending(state, action.meta.requestId); + }) .addCase(fetchMoreProducts.pending, (state, action) => { handlePending(state, action.meta.requestId); }) From cbd1e791e97983e80f401aeab0e2c3b74c067d12 Mon Sep 17 00:00:00 2001 From: Tooni Date: Thu, 4 Dec 2025 11:42:12 +0000 Subject: [PATCH 40/41] add id field --- packages/headless/src/api/commerce/common/result.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/headless/src/api/commerce/common/result.ts b/packages/headless/src/api/commerce/common/result.ts index c3db973bccb..714951ca549 100644 --- a/packages/headless/src/api/commerce/common/result.ts +++ b/packages/headless/src/api/commerce/common/result.ts @@ -7,6 +7,10 @@ export enum ResultType { } export interface BaseSpotlightContent { + /** + * The unique identifier of the spotlight content. + */ + id: string; /** * The URI to navigate to when the spotlight content is clicked. */ From 768c13085e5756b46f836b931336b3818c142bae Mon Sep 17 00:00:00 2001 From: Tooni Date: Thu, 4 Dec 2025 14:00:40 +0000 Subject: [PATCH 41/41] fix type --- packages/headless/src/test/mock-spotlight-content.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/headless/src/test/mock-spotlight-content.ts b/packages/headless/src/test/mock-spotlight-content.ts index 360f9b94f0e..b6880bd004a 100644 --- a/packages/headless/src/test/mock-spotlight-content.ts +++ b/packages/headless/src/test/mock-spotlight-content.ts @@ -8,6 +8,7 @@ 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',