From 796ab8973b5f1a892deba9acc89eb478d5bb9efd Mon Sep 17 00:00:00 2001 From: Eduardo Paulos Date: Mon, 27 Nov 2023 17:03:53 +0000 Subject: [PATCH] feat: join relatedCommercedata with editorial data --- .../__tests__/useProductListing.test.tsx | 73 ++++ .../products/hooks/types/useProductListing.ts | 1 + .../src/products/hooks/useProductListing.ts | 13 + .../__snapshots__/index.test.ts.snap | 2 + .../redux/src/contents/serverInitialState.ts | 14 +- .../factories/fetchCustomListingFactory.ts | 46 +++ .../factories/fetchProductListFactory.ts | 33 +- .../factories/fetchProductListingFactory.ts | 1 + .../factories/fetchProductSetFactory.ts | 1 + .../src/products/actions/factories/index.ts | 1 + .../products/actions/fetchCustomListing.ts | 7 + packages/redux/src/products/actions/index.ts | 1 + .../__snapshots__/lists.test.ts.snap | 362 +++++++++++++++++- .../__tests__/lists.test.ts | 47 ++- .../products/serverInitialState/listings.ts | 65 +++- .../generateProductListingHash.test.ts | 16 + .../products/utils/__tests__/getSlug.test.ts | 8 + .../utils/generateProductListingHash.ts | 7 +- packages/redux/src/products/utils/getSlug.ts | 8 +- .../types/generateProductListingHash.types.ts | 2 + packages/redux/src/types/model.types.ts | 12 +- .../products/productsLists.fixtures.mts | 220 +++++++++++ .../__fixtures__/products/state.fixtures.mts | 30 +- 23 files changed, 939 insertions(+), 31 deletions(-) create mode 100644 packages/redux/src/products/actions/factories/fetchCustomListingFactory.ts create mode 100644 packages/redux/src/products/actions/fetchCustomListing.ts diff --git a/packages/react/src/products/hooks/__tests__/useProductListing.test.tsx b/packages/react/src/products/hooks/__tests__/useProductListing.test.tsx index 3ed114840..cd937253f 100644 --- a/packages/react/src/products/hooks/__tests__/useProductListing.test.tsx +++ b/packages/react/src/products/hooks/__tests__/useProductListing.test.tsx @@ -1,4 +1,5 @@ import { + fetchCustomListing, fetchProductListing, fetchProductSet, getSlug, @@ -7,6 +8,8 @@ import { import { mockBrandResponse } from 'tests/__fixtures__/brands/index.mjs'; import { mockCategory } from 'tests/__fixtures__/categories/index.mjs'; import { + mockCustomListingPageHash, + mockCustomListingPageState, mockProductsListDenormalizedFacetGroups, mockProductsListHash, mockProductsListHashWithoutParameters, @@ -25,6 +28,7 @@ jest.mock('@farfetch/blackout-redux', () => ({ ...jest.requireActual('@farfetch/blackout-redux'), fetchProductListing: jest.fn(() => () => Promise.resolve()), fetchProductSet: jest.fn(() => () => Promise.resolve()), + fetchCustomListing: jest.fn(() => () => Promise.resolve()), resetProductListings: jest.fn(() => () => Promise.resolve()), })); @@ -249,6 +253,47 @@ describe('useProductListing', () => { }, }); }); + + it('should return data correctly when `isACustomListingPage` is true', () => { + const slug = getSlug('en/customlistingpage', true); + + const { result } = renderHook( + () => useProductListing(slug, { isACustomListingPage: true }), + { + wrapper: withStore(mockCustomListingPageState), + }, + ); + + const mockList = + mockProductsListNormalizedPayload.entities.productsLists[ + mockCustomListingPageHash + ]; + + expect(fetchCustomListing).not.toHaveBeenCalled(); + + expect(result.current).toStrictEqual({ + error: undefined, + isFetched: true, + isLoading: false, + data: { + ...mockList, + facetGroups: + mockProductsListDenormalizedFacetGroups[mockCustomListingPageHash], + hash: mockCustomListingPageHash, + items: expectedProductsDenormalized, + pagination: { + number: mockList.products.number, + pageSize: mockList.config.pageSize, + totalItems: mockList.products.totalItems, + totalPages: mockList.products.totalPages, + }, + }, + actions: { + reset: expect.any(Function), + refetch: expect.any(Function), + }, + }); + }); }); describe('actions', () => { @@ -326,5 +371,33 @@ describe('useProductListing', () => { undefined, ); }); + + it('should call `fetchCustomListing` successfully when `refetch` action is called and type is `listing`', () => { + const { + result: { + current: { + actions: { refetch }, + }, + }, + } = renderHook( + () => + useProductListing(slug, { + useCache: false, + isACustomListingPage: true, + }), + { + wrapper: withStore(mockCustomListingPageState), + }, + ); + + refetch(); + + expect(fetchCustomListing).toHaveBeenCalledWith( + slug, + undefined, + { setProductsListHash: undefined, useCache: false }, + undefined, + ); + }); }); }); diff --git a/packages/react/src/products/hooks/types/useProductListing.ts b/packages/react/src/products/hooks/types/useProductListing.ts index ecd482600..1d01a54c0 100644 --- a/packages/react/src/products/hooks/types/useProductListing.ts +++ b/packages/react/src/products/hooks/types/useProductListing.ts @@ -15,6 +15,7 @@ export interface UseProductListingCommonOptions { useCache?: boolean; enableAutoFetch?: boolean; setProductsListHash?: boolean; + isACustomListingPage?: boolean; fetchConfig?: Config; } diff --git a/packages/react/src/products/hooks/useProductListing.ts b/packages/react/src/products/hooks/useProductListing.ts index 53a47ad4a..879ef8df7 100644 --- a/packages/react/src/products/hooks/useProductListing.ts +++ b/packages/react/src/products/hooks/useProductListing.ts @@ -1,4 +1,5 @@ import { + fetchCustomListing, fetchProductListing, fetchProductSet, generateProductListingHash, @@ -39,15 +40,18 @@ const useProductListing = ( useCache = true, setProductsListHash, enableAutoFetch = true, + isACustomListingPage = false, } = options; const isSetPage = isUseProductListingTypeSetOptions(options); const productListingHash = generateProductListingHash(slug, query, { isSet: isSetPage, + isACustomListingPage, }); const fetchListingAction = useAction(fetchProductListing); const fetchSetAction = useAction(fetchProductSet); + const fetchCustomListingAction = useAction(fetchCustomListing); const resetAction = useAction(resetProductListings); const reset = useCallback(() => { resetAction([productListingHash]); @@ -84,6 +88,13 @@ const useProductListing = ( { useCache, setProductsListHash }, fetchConfig, ) + : isACustomListingPage + ? fetchCustomListingAction( + slug, + options.query, + { useCache, setProductsListHash }, + fetchConfig, + ) : fetchListingAction( slug, options.query, @@ -99,6 +110,8 @@ const useProductListing = ( setProductsListHash, slug, useCache, + isACustomListingPage, + fetchCustomListingAction, ], ); diff --git a/packages/redux/src/__tests__/__snapshots__/index.test.ts.snap b/packages/redux/src/__tests__/__snapshots__/index.test.ts.snap index 5f333520d..718ab75f8 100644 --- a/packages/redux/src/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/redux/src/__tests__/__snapshots__/index.test.ts.snap @@ -705,6 +705,8 @@ Object { "fetchCountryStateCitiesFactory": [Function], "fetchCountryStates": [Function], "fetchCountryStatesFactory": [Function], + "fetchCustomListing": [Function], + "fetchCustomListingFactory": [Function], "fetchExchange": [Function], "fetchExchangeBookRequest": [Function], "fetchExchangeBookRequestFactory": [Function], diff --git a/packages/redux/src/contents/serverInitialState.ts b/packages/redux/src/contents/serverInitialState.ts index 9a8b47f8b..997448822 100644 --- a/packages/redux/src/contents/serverInitialState.ts +++ b/packages/redux/src/contents/serverInitialState.ts @@ -23,16 +23,14 @@ const serverInitialState: ServerInitialState = ({ model }) => { const { searchContentRequests, slug, seoMetadata, subfolder } = model; const contents = searchContentRequests.reduce((acc, item) => { - const { searchResponse } = item; - const firstSearchResponseItem = searchResponse.entries[0]; - - if (!firstSearchResponseItem) { - return acc; - } + const { + filters: { codes, contentTypeCode }, + searchResponse, + } = item; const hash = generateContentHash({ - codes: firstSearchResponseItem.code, - contentTypeCode: firstSearchResponseItem.contentTypeCode, + codes: codes, + contentTypeCode: contentTypeCode, }); const { entities, result } = { diff --git a/packages/redux/src/products/actions/factories/fetchCustomListingFactory.ts b/packages/redux/src/products/actions/factories/fetchCustomListingFactory.ts new file mode 100644 index 000000000..8c1f74ecf --- /dev/null +++ b/packages/redux/src/products/actions/factories/fetchCustomListingFactory.ts @@ -0,0 +1,46 @@ +import fetchProductListFactory from './fetchProductListFactory.js'; +import type { + Config, + GetProductListing, + GetProductListingQuery, + ProductListing, +} from '@farfetch/blackout-client'; +import type { Dispatch } from 'redux'; +import type { GetOptionsArgument, StoreState } from '../../../types/index.js'; +import type { ProductListActionOptions } from '../../types/index.js'; + +/** + * Creates a thunk factory configured with the specified client to fetch a specific + * set by its id. + * + * @param getProductSet - Get product set client. + * + * @returns Thunk factory. + */ +const fetchCustomListingFactory = + (getListing: GetProductListing) => + ( + slug: string | number, + query: GetProductListingQuery = {}, + actionOptions?: ProductListActionOptions, + config?: Config, + ) => + ( + dispatch: Dispatch, + getState: () => StoreState, + options: GetOptionsArgument, + ): Promise => + fetchProductListFactory( + getListing, + slug, + query, + actionOptions, + config, + dispatch, + getState, + options, + false, + true, + ) as Promise; + +export default fetchCustomListingFactory; diff --git a/packages/redux/src/products/actions/factories/fetchProductListFactory.ts b/packages/redux/src/products/actions/factories/fetchProductListFactory.ts index 8e49163d0..5de8c7c5c 100644 --- a/packages/redux/src/products/actions/factories/fetchProductListFactory.ts +++ b/packages/redux/src/products/actions/factories/fetchProductListFactory.ts @@ -28,15 +28,16 @@ import type { ProductListActionOptions } from '../../types/index.js'; * Creates a thunk configured with the specified client to fetch a product listing * for a given slug with specific query parameters. * - * @param client - Get listing or sets client. - * @param slug - Slug to load product list for. - * @param query - Query parameters to apply. - * @param actionOptions - Additional options to apply to the action. - * @param config - Custom configurations to send to the client instance (axios). - * @param dispatch - Redux dispatch. - * @param getState - Store state. - * @param options - Thunk options. - * @param isSet - If is sets scope or not. + * @param client - Get listing or sets client. + * @param slug - Slug to load product list for. + * @param query - Query parameters to apply. + * @param actionOptions - Additional options to apply to the action. + * @param config - Custom configurations to send to the client instance (axios). + * @param dispatch - Redux dispatch. + * @param getState - Store state. + * @param options - Thunk options. + * @param isSet - If is sets scope or not. + * @param isACustomListingPage - If is custom listing page scope or not. * * @returns Thunk to be dispatched to the redux store. */ @@ -55,11 +56,15 @@ const fetchProductListFactory = async ( getOptions = arg => ({ productImgQueryParam: arg.productImgQueryParam }), }: GetOptionsArgument, isSet: boolean, + isACustomListingPage: boolean, ): Promise => { let hash: Nullable = null; try { - hash = generateProductListingHash(slug, query, { isSet }); + hash = generateProductListingHash(slug, query, { + isSet, + isACustomListingPage, + }); const { productImgQueryParam } = getOptions(getState); const isHydrated = isProductListingHydrated(getState(), hash); @@ -108,8 +113,12 @@ const fetchProductListFactory = async ( type: actionTypes.FETCH_PRODUCT_LISTING_REQUEST, }); - // @ts-expect-error Property slug can be a string or a number. - const result = await client(slug, query, config); + const result = await client( + // @ts-expect-error Property slug can be a string or a number. + isACustomListingPage ? '' : slug, + query, + config, + ); dispatch({ meta: { hash }, diff --git a/packages/redux/src/products/actions/factories/fetchProductListingFactory.ts b/packages/redux/src/products/actions/factories/fetchProductListingFactory.ts index 7dd588142..eced62806 100644 --- a/packages/redux/src/products/actions/factories/fetchProductListingFactory.ts +++ b/packages/redux/src/products/actions/factories/fetchProductListingFactory.ts @@ -41,6 +41,7 @@ const fetchProductListingFactory = getState, options, false, + false, ); export default fetchProductListingFactory; diff --git a/packages/redux/src/products/actions/factories/fetchProductSetFactory.ts b/packages/redux/src/products/actions/factories/fetchProductSetFactory.ts index fdadf24ff..bb0ad9685 100644 --- a/packages/redux/src/products/actions/factories/fetchProductSetFactory.ts +++ b/packages/redux/src/products/actions/factories/fetchProductSetFactory.ts @@ -40,6 +40,7 @@ const fetchProductSetFactory = getState, options, true, + false, ) as Promise; export default fetchProductSetFactory; diff --git a/packages/redux/src/products/actions/factories/index.ts b/packages/redux/src/products/actions/factories/index.ts index 959441e73..d1768a308 100644 --- a/packages/redux/src/products/actions/factories/index.ts +++ b/packages/redux/src/products/actions/factories/index.ts @@ -13,6 +13,7 @@ export { default as fetchProductListingFactory } from './fetchProductListingFact export { default as fetchProductMeasurementsFactory } from './fetchProductMeasurementsFactory.js'; export { default as fetchProductOutfitsFactory } from './fetchProductOutfitsFactory.js'; export { default as fetchProductSetFactory } from './fetchProductSetFactory.js'; +export { default as fetchCustomListingFactory } from './fetchCustomListingFactory.js'; export { default as fetchProductSizeGuidesFactory } from './fetchProductSizeGuidesFactory.js'; export { default as fetchProductSizesFactory } from './fetchProductSizesFactory.js'; export { default as fetchProductVariantsByMerchantsLocationsFactory } from './fetchProductVariantsByMerchantsLocationsFactory.js'; diff --git a/packages/redux/src/products/actions/fetchCustomListing.ts b/packages/redux/src/products/actions/fetchCustomListing.ts new file mode 100644 index 000000000..fdc046d7f --- /dev/null +++ b/packages/redux/src/products/actions/fetchCustomListing.ts @@ -0,0 +1,7 @@ +import { fetchCustomListingFactory } from './factories/index.js'; +import { getProductListing } from '@farfetch/blackout-client'; + +/** + * Fetch a specific custom listing by its id. + */ +export default fetchCustomListingFactory(getProductListing); diff --git a/packages/redux/src/products/actions/index.ts b/packages/redux/src/products/actions/index.ts index 1180b3179..f2e9684d2 100644 --- a/packages/redux/src/products/actions/index.ts +++ b/packages/redux/src/products/actions/index.ts @@ -14,6 +14,7 @@ export { default as fetchProductListing } from './fetchProductListing.js'; export { default as fetchProductMeasurements } from './fetchProductMeasurements.js'; export { default as fetchProductOutfits } from './fetchProductOutfits.js'; export { default as fetchProductSet } from './fetchProductSet.js'; +export { default as fetchCustomListing } from './fetchCustomListing.js'; export { default as fetchProductSizeGuides } from './fetchProductSizeGuides.js'; export { default as fetchProductSizes } from './fetchProductSizes.js'; export { default as fetchProductVariantsByMerchantsLocations } from './fetchProductVariantsByMerchantsLocations.js'; diff --git a/packages/redux/src/products/serverInitialState/__tests__/__snapshots__/lists.test.ts.snap b/packages/redux/src/products/serverInitialState/__tests__/__snapshots__/lists.test.ts.snap index 97f2f4629..48c759294 100644 --- a/packages/redux/src/products/serverInitialState/__tests__/__snapshots__/lists.test.ts.snap +++ b/packages/redux/src/products/serverInitialState/__tests__/__snapshots__/lists.test.ts.snap @@ -1,5 +1,365 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`products lists serverInitialState() should initialize server and throw a error for a non product list and is a listing 1`] = ` +Object { + "lists": Object { + "error": Object { + "listing": [Error: Unexpected error], + }, + "hash": "listing", + "isHydrated": Object { + "listing": true, + }, + "isLoading": Object { + "listing": false, + }, + "productListingFacets": Object { + "error": null, + "isLoading": false, + "result": Array [], + }, + }, +} +`; + +exports[`products lists serverInitialState() should initialize server state for CustomListingPage 1`] = ` +Object { + "entities": Object { + "facets": Object { + "categories_144307_listing/customlistingpage": Object { + "_isActive": false, + "_isDisabled": false, + "count": 13, + "description": "Women", + "groupType": 6, + "groupsOn": 0, + "id": "categories_144307_listing/customlistingpage", + "parentId": "categories_0_listing/customlistingpage", + "slug": "women", + "url": "women", + "value": 144307, + "valueUpperBound": 0, + }, + "categories_144331_listing/customlistingpage": Object { + "_isActive": false, + "_isDisabled": false, + "count": 2, + "description": "Accessories", + "groupType": 6, + "groupsOn": 0, + "id": "categories_144331_listing/customlistingpage", + "parentId": "categories_144307_listing/customlistingpage", + "slug": "women-accessories", + "url": "women-accessories", + "value": 144331, + "valueUpperBound": 0, + }, + "categories_144424_listing/customlistingpage": Object { + "_isActive": false, + "_isDisabled": false, + "count": 1, + "description": "Special Accessories", + "groupType": 6, + "groupsOn": 0, + "id": "categories_144424_listing/customlistingpage", + "parentId": "categories_144418_listing/customlistingpage", + "slug": "women-specials-special-accessories", + "url": "women-specials-special-accessories", + "value": 144424, + "valueUpperBound": 0, + }, + "colors_1_listing/customlistingpage": Object { + "_isActive": false, + "_isDisabled": false, + "count": 4, + "description": "BLACK", + "groupType": 11, + "groupsOn": 0, + "id": "colors_1_listing/customlistingpage", + "parentId": "colors_0_listing/customlistingpage", + "slug": "colors", + "url": "?colors=1", + "value": 1, + "valueUpperBound": 0, + }, + "pricetype_0_listing/customlistingpage": Object { + "_isActive": false, + "_isDisabled": false, + "count": 13, + "description": "FullPrice", + "groupType": 14, + "groupsOn": 0, + "id": "pricetype_0_listing/customlistingpage", + "parentId": "pricetype_0_listing/customlistingpage", + "slug": "fullprice", + "url": "fullprice", + "value": 0, + "valueUpperBound": 0, + }, + "sizes_22_144307_listing/customlistingpage": Object { + "_isActive": false, + "_isDisabled": false, + "count": 5, + "description": "36", + "groupType": 9, + "groupsOn": 144307, + "id": "sizes_22_144307_listing/customlistingpage", + "parentId": "sizes_0_listing/customlistingpage", + "slug": "sizes", + "url": "?sizes=22", + "value": 22, + "valueUpperBound": 0, + }, + }, + "products": Object { + "123": Object { + "customAttributes": undefined, + "groupedEntries": undefined, + "id": 123, + "images": undefined, + "merchant": undefined, + "price": Object { + "discount": Object { + "excludingTaxes": undefined, + "includingTaxes": undefined, + "rate": undefined, + }, + "excludingTaxes": undefined, + "formatted": Object { + "includingTaxes": undefined, + "includingTaxesWithoutDiscount": undefined, + }, + "includingTaxes": undefined, + "includingTaxesWithoutDiscount": undefined, + "isFormatted": true, + "priceType": undefined, + "promotionType": undefined, + "tags": undefined, + "taxes": Object { + "amount": undefined, + "rate": undefined, + "type": undefined, + }, + "type": undefined, + }, + "prices": undefined, + "sizes": undefined, + "tag": Object { + "id": undefined, + "name": undefined, + }, + "variants": undefined, + }, + "321": Object { + "customAttributes": undefined, + "groupedEntries": undefined, + "id": 321, + "images": undefined, + "merchant": undefined, + "price": Object { + "discount": Object { + "excludingTaxes": undefined, + "includingTaxes": undefined, + "rate": undefined, + }, + "excludingTaxes": undefined, + "formatted": Object { + "includingTaxes": undefined, + "includingTaxesWithoutDiscount": undefined, + }, + "includingTaxes": undefined, + "includingTaxesWithoutDiscount": undefined, + "isFormatted": true, + "priceType": undefined, + "promotionType": undefined, + "tags": undefined, + "taxes": Object { + "amount": undefined, + "rate": undefined, + "type": undefined, + }, + "type": undefined, + }, + "prices": undefined, + "sizes": undefined, + "tag": Object { + "id": undefined, + "name": undefined, + }, + "variants": undefined, + }, + }, + "productsLists": Object { + "listing/customlistingpage": Object { + "breadCrumbs": Array [], + "config": Object { + "pageIndex": 1, + }, + "didYouMean": undefined, + "facetGroups": Array [ + Object { + "_clearUrl": null, + "_isClearHidden": false, + "_isClosed": false, + "deep": 1, + "description": "Categories", + "dynamic": 0, + "format": "hierarchical", + "hash": "listing/customlistingpage", + "key": "categories", + "order": 0, + "type": 6, + "values": Array [ + Array [ + "categories_144307_listing/customlistingpage", + ], + ], + }, + Object { + "_clearUrl": null, + "_isClearHidden": false, + "_isClosed": false, + "deep": 2, + "description": "Categories", + "dynamic": 0, + "format": "hierarchical", + "hash": "listing/customlistingpage", + "key": "categories", + "order": 0, + "type": 6, + "values": Array [ + Array [ + "categories_144331_listing/customlistingpage", + ], + ], + }, + Object { + "_clearUrl": null, + "_isClearHidden": false, + "_isClosed": false, + "deep": 3, + "description": "Categories", + "dynamic": 0, + "format": "hierarchical", + "hash": "listing/customlistingpage", + "key": "categories", + "order": 0, + "type": 6, + "values": Array [ + Array [ + "categories_144424_listing/customlistingpage", + ], + ], + }, + Object { + "_clearUrl": null, + "_isClearHidden": false, + "_isClosed": false, + "deep": 0, + "description": "Colors", + "dynamic": 0, + "format": "multiple", + "hash": "listing/customlistingpage", + "key": "colors", + "order": 5, + "type": 11, + "values": Array [ + Array [ + "colors_1_listing/customlistingpage", + ], + ], + }, + Object { + "_clearUrl": null, + "_isClearHidden": false, + "_isClosed": false, + "deep": 0, + "description": "PriceType", + "dynamic": 0, + "format": "multiple", + "hash": "listing/customlistingpage", + "key": "pricetype", + "order": 6, + "type": 14, + "values": Array [ + Array [ + "pricetype_0_listing/customlistingpage", + ], + ], + }, + Object { + "_clearUrl": null, + "_isClearHidden": false, + "_isClosed": false, + "deep": 0, + "description": "Sizes", + "dynamic": 0, + "format": "multiple", + "hash": "listing/customlistingpage", + "key": "sizes", + "order": 4, + "type": 9, + "values": Array [ + Array [ + "sizes_22_144307_listing/customlistingpage", + ], + ], + }, + ], + "facetsBaseUrl": "/en-pt/shopping", + "filterSegments": Array [ + Object { + "deep": 1, + "description": "Women", + "facetId": "categories_144307_listing/customlistingpage", + "fromQueryString": false, + "gender": 0, + "key": "categories", + "negativeFilter": false, + "order": 0, + "parentId": 0, + "slug": "women", + "type": 6, + "value": 144307, + "valueUpperBound": 0, + }, + ], + "gender": 0, + "genderName": "Woman", + "hash": "listing/customlistingpage", + "name": "New arrivals", + "products": Object { + "entries": Array [ + 123, + 321, + ], + "totalItems": 193, + "totalPages": 10, + }, + "redirectInformation": null, + "searchTerm": null, + "slug": "/en-pt/customlistingpage", + }, + }, + }, + "lists": Object { + "error": Object {}, + "hash": "listing/customlistingpage", + "isHydrated": Object { + "listing/customlistingpage": true, + }, + "isLoading": Object { + "listing/customlistingpage": false, + }, + "productListingFacets": Object { + "error": null, + "isLoading": false, + "result": Array [], + }, + }, +} +`; + exports[`products lists serverInitialState() should initialize server state for a listing 1`] = ` Object { "entities": Object { @@ -339,7 +699,7 @@ Object { } `; -exports[`products lists serverInitialState() should initialize server state for a non product list 1`] = ` +exports[`products lists serverInitialState() should initialize server state for a non product list with initial state 1`] = ` Object { "lists": Object { "error": Object {}, diff --git a/packages/redux/src/products/serverInitialState/__tests__/lists.test.ts b/packages/redux/src/products/serverInitialState/__tests__/lists.test.ts index a5febe71e..66153e435 100644 --- a/packages/redux/src/products/serverInitialState/__tests__/lists.test.ts +++ b/packages/redux/src/products/serverInitialState/__tests__/lists.test.ts @@ -31,6 +31,19 @@ describe('products lists serverInitialState()', () => { expect(state).toMatchSnapshot(); }); + it('should initialize server state for CustomListingPage', () => { + const slug = '/en-pt/customlistingpage'; + // @ts-expect-error A lot of properties would need to be added to make the value comply with the type which are irrelevant for the test + const model = { + relatedCommerceData: { referencedListing: [mockProductsListModel] }, + slug, + } as Model; + + const state = listsServerInitialState({ model }); + + expect(state).toMatchSnapshot(); + }); + it('should build the correct sorted hash with encoded query params', () => { const slug = '/en-pt/shopping?colors=11%7C6&another=foo'; const expectedHash = 'listing?colors=11|6&another=foo'; @@ -61,10 +74,42 @@ describe('products lists serverInitialState()', () => { expect(state.lists.hash).toBe(expectedHash); }); - it('should initialize server state for a non product list', () => { + it('should build the correct hash when is a custom listing page', () => { + const slug = '/en-pt/customlistingpage'; + const expectedHash = 'listing/customlistingpage'; + // @ts-expect-error A lot of properties would need to be added to make the value comply with the type which are irrelevant for the test + const model = { + relatedCommerceData: { referencedListing: [mockProductsListModel] }, + slug, + } as Model; + const state = listsServerInitialState({ model }); + + expect(Object.keys(state.entities!.productsLists!)).toEqual( + expect.arrayContaining([expectedHash]), + ); + expect(state.lists.hash).toBe(expectedHash); + }); + + it('should initialize server state for a non product list with initial state', () => { const model = {} as Model; const state = listsServerInitialState({ model }); expect(state).toMatchSnapshot(); }); + + it('should initialize server and throw a error for a non product list and is a listing', () => { + const slug = '/en-pt/shopping'; + const expectedHash = 'listing'; + const model = { + dataLayer: { general: { type: 'Listing' } }, + slug, + } as Model; + + const state = listsServerInitialState({ model }); + + expect(state).toMatchSnapshot(); + expect(Object.keys(state.lists.error)).toEqual( + expect.arrayContaining([expectedHash]), + ); + }); }); diff --git a/packages/redux/src/products/serverInitialState/listings.ts b/packages/redux/src/products/serverInitialState/listings.ts index 3388dc2a1..cc3b8c96f 100644 --- a/packages/redux/src/products/serverInitialState/listings.ts +++ b/packages/redux/src/products/serverInitialState/listings.ts @@ -1,5 +1,5 @@ import { generateProductListingHash, getSlug } from '../utils/index.js'; -import { get } from 'lodash-es'; +import { get, isEmpty } from 'lodash-es'; import { INITIAL_STATE } from '../reducer/lists.js'; import { normalize } from 'normalizr'; import { toBlackoutError } from '@farfetch/blackout-client'; @@ -27,14 +27,75 @@ const serverInitialState: ProductListingsServerInitialState = ({ const dataLayerType = model?.dataLayer?.general?.type; const isListing = dataLayerType === 'Listing'; + const isCustomListingPage = !isEmpty( + model?.relatedCommerceData?.referencedListing, + ); - const builtSlug = getSlug(pathname); + const builtSlug = getSlug(pathname, isCustomListingPage); const isSetFallback = /\/sets\//.test(pathname) && isListing; const isSet = model?.pageType === 'set' || isSetFallback; const hash = generateProductListingHash(builtSlug, query, { isSet, }); + if (get(model, 'relatedCommerceData.referencedListing')) { + const { + breadCrumbs, + config, + didYouMean, + facetGroups, + facetsBaseUrl, + filterSegments, + gender, + genderName, + name, + products, + redirectInformation, + searchTerm, + } = model.relatedCommerceData?.referencedListing?.[0]; + + // Normalize it + const { entities } = normalize( + { + breadCrumbs, + config, + didYouMean, + facetGroups, + facetsBaseUrl, + filterSegments, + gender, + genderName, + hash, + name, + productImgQueryParam, // Send this to the entity's `adaptProductImages` + products, + redirectInformation, + searchTerm, + slug, + }, + productsList, + ); + + return { + lists: { + error: {}, + hash, + isHydrated: { + [hash]: true, + }, + isLoading: { + [hash]: false, + }, + productListingFacets: { + isLoading: false, + error: null, + result: [], + }, + }, + entities, + }; + } + if (!get(model, 'products')) { if (isListing) { const error = toBlackoutError({}); diff --git a/packages/redux/src/products/utils/__tests__/generateProductListingHash.test.ts b/packages/redux/src/products/utils/__tests__/generateProductListingHash.test.ts index 38ea93a49..0931943ce 100644 --- a/packages/redux/src/products/utils/__tests__/generateProductListingHash.test.ts +++ b/packages/redux/src/products/utils/__tests__/generateProductListingHash.test.ts @@ -44,4 +44,20 @@ describe('generateProductListingHash', () => { expect(result).toBe(expectedResult); }); + + it('should correctly construct the product list hash when a custom listing page', () => { + const mockQueryString = undefined; + const expectedResult = 'listing/woman/clothing'; + const isACustomListingPage = true; + + const result = generateProductListingHash( + mockProductsListSlug, + mockQueryString, + { + isACustomListingPage, + }, + ); + + expect(result).toBe(expectedResult); + }); }); diff --git a/packages/redux/src/products/utils/__tests__/getSlug.test.ts b/packages/redux/src/products/utils/__tests__/getSlug.test.ts index 210fabcf5..10b895a6e 100644 --- a/packages/redux/src/products/utils/__tests__/getSlug.test.ts +++ b/packages/redux/src/products/utils/__tests__/getSlug.test.ts @@ -24,4 +24,12 @@ describe('getSlug', () => { expect(result).toBe(expectedResult); }); + + it('should return the second segment if it is a custom listing page', () => { + const mockPathname = '/us/elephant'; + const expectedResult = '/elephant'; + const result = getSlug(mockPathname, true); + + expect(result).toBe(expectedResult); + }); }); diff --git a/packages/redux/src/products/utils/generateProductListingHash.ts b/packages/redux/src/products/utils/generateProductListingHash.ts index 2fdd1e61c..9db3c29bd 100644 --- a/packages/redux/src/products/utils/generateProductListingHash.ts +++ b/packages/redux/src/products/utils/generateProductListingHash.ts @@ -27,7 +27,7 @@ import type { GenerateProductListingHash } from './types/index.js'; const generateProductListingHash: GenerateProductListingHash = ( slug, query, - { isSet } = {}, + { isSet, isACustomListingPage } = {}, ) => { let finalQuery = {}; let productsListScope = 'listing'; @@ -56,6 +56,11 @@ const generateProductListingHash: GenerateProductListingHash = ( slug && (typeof slug === 'number' || slug.charAt(0) !== '/') ? `/${slug}` : slug; + + if (isACustomListingPage) { + return `${productsListScope}${parsedSlug}`; + } + const parsedQueryString = buildQueryStringFromObject(finalQuery); return `${productsListScope}${parsedSlug}${parsedQueryString}`; diff --git a/packages/redux/src/products/utils/getSlug.ts b/packages/redux/src/products/utils/getSlug.ts index 7c012adf4..fa0d34ffc 100644 --- a/packages/redux/src/products/utils/getSlug.ts +++ b/packages/redux/src/products/utils/getSlug.ts @@ -14,12 +14,18 @@ import join from 'proper-url-join'; * * @returns Result with the correct path do call the endpoint. */ -const getSlug = (pathname: string): string => { +const getSlug = (pathname: string, isCustomListingPage?: boolean): string => { const segments = pathname.replace(/\/+$/, '').split('/'); const type = segments.find(entry => ['shopping', 'sets', 'categories'].includes(entry), ); + // When it is a Custom Listing Page it will return the thirth segment + // Example: '/en-pt/customlisting' + if (isCustomListingPage) { + return `/${segments[segments.length - 1]}`; + } + if (!type) { return ''; } diff --git a/packages/redux/src/products/utils/types/generateProductListingHash.types.ts b/packages/redux/src/products/utils/types/generateProductListingHash.types.ts index 93e1b9baf..e380d1275 100644 --- a/packages/redux/src/products/utils/types/generateProductListingHash.types.ts +++ b/packages/redux/src/products/utils/types/generateProductListingHash.types.ts @@ -13,5 +13,7 @@ export type GenerateProductListingHash = ( options?: { // If the hash represents a set or not. isSet?: boolean; + // If the slug represents a customListingPage or not. + isACustomListingPage?: boolean; }, ) => ProductListingEntity['hash']; diff --git a/packages/redux/src/types/model.types.ts b/packages/redux/src/types/model.types.ts index 4309973a8..4c9269ade 100644 --- a/packages/redux/src/types/model.types.ts +++ b/packages/redux/src/types/model.types.ts @@ -55,7 +55,17 @@ type Common = { }; }; +type RelatedCommerceData = { + referencedListing: [ProductListing & { explanation?: string }]; + referencedSets: []; + referencedBrands: []; + referencedCategories: []; + referencedProducts: []; +}; + export type Model = ProductListing & ProductSet & Product & - Common & { seoMetadata: SEOMetadata }; + Common & { relatedCommerceData: RelatedCommerceData } & { + seoMetadata: SEOMetadata; + }; diff --git a/tests/__fixtures__/products/productsLists.fixtures.mts b/tests/__fixtures__/products/productsLists.fixtures.mts index 0bfe99376..c295cdc3f 100644 --- a/tests/__fixtures__/products/productsLists.fixtures.mts +++ b/tests/__fixtures__/products/productsLists.fixtures.mts @@ -26,6 +26,7 @@ export const mockQueryWithoutPageIndex = { categories: '135971', colors: '6', }; +export const mockCustomListingPageSlug = '/customlistingpage'; export const mockProductsListHashWithoutParameters = 'listing/woman/clothing'; export const mockProductsListHash = 'listing/woman/clothing?categories=135971&colors=6&pageindex=1'; @@ -41,6 +42,7 @@ export const mockProductsListHashWithProductIds = export const mockProductsListHashForSets = 'sets/woman/clothing?categories=135971&colors=6&pageindex=1'; export const mockProductsListHashForSetsWithId = `sets/${mockSetId}`; +export const mockCustomListingPageHash = 'listing/customlistingpage'; export const mockProductsListResponse = { breadCrumbs: [], config: { @@ -1339,6 +1341,64 @@ export const mockProductsListDenormalizedFacetGroups = { hash: mockProductsListHashWithoutParameters, }, ], + [mockCustomListingPageHash]: [ + { + deep: 1, + description: 'Categories', + dynamic: 0, + format: FacetGroupFormat.Hierarchical, + key: FacetGroupKey.Categories, + type: 6, + values: [{ ...mockFacets[0], _isDisabled: false, _isActive: false }], + _clearUrl: '', + _isClearHidden: true, + _isClosed: true, + order: 1, + hash: mockCustomListingPageHash, + }, + { + deep: 2, + description: 'Categories', + dynamic: 0, + format: FacetGroupFormat.Hierarchical, + key: FacetGroupKey.Categories, + type: 6, + values: [{ ...mockFacets[1], _isDisabled: false, _isActive: false }], + _clearUrl: '', + _isClearHidden: true, + _isClosed: true, + order: 2, + hash: mockCustomListingPageHash, + }, + { + deep: 1, + description: 'Colors', + dynamic: 0, + format: FacetGroupFormat.Multiple, + key: FacetGroupKey.Colors, + type: 11, + values: [{ ...mockFacets[2], _isDisabled: false, _isActive: false }], + _clearUrl: '', + _isClearHidden: true, + _isClosed: true, + order: 1, + hash: mockCustomListingPageHash, + }, + { + deep: 0, + description: 'SizesByCategory', + dynamic: 136301, + format: FacetGroupFormat.Multiple, + key: FacetGroupKey.SizesByCategory, + order: 5, + type: 24, + values: [], + _clearUrl: '', + _isClearHidden: true, + _isClosed: true, + hash: mockCustomListingPageHash, + }, + ], }; export const mockProductsListNormalizedPayload = { productsList: { hash: mockProductsListHash }, @@ -2080,6 +2140,126 @@ export const mockProductsListNormalizedPayload = { genderName: '', hash: mockProductsListHashForSetsWithId, }, + [mockCustomListingPageHash]: { + products: { + entries: [12913172, 12913174], + number: 1, + totalItems: 2, + totalPages: 1, + }, + config: { + pageIndex: 1, + pageSize: 20, + sort: 'BRAND', + sortDirection: 'ASC', + mobilePageSize: 1, + filtersStartHidden: false, + filterTypes: [], + noResultsImageUrl: '', + discount: null, + availableSorts: [], + query: null, + encodedQuery: '', + removeSingleValueFacets: false, + mixedMode: { + endDate: '/Date(-62029382400000)/', + forceFullPrice: false, + startDate: '/Date(-62040960000000)/', + }, + imageSizes: [], + showChildrenCategories: false, + contextFilters: '', + scenarios: '', + }, + facetGroups: [ + { + deep: 1, + description: 'Categories', + dynamic: 0, + format: FacetGroupFormat.Hierarchical, + key: FacetGroupKey.Categories, + type: 6, + values: [[mockFacets[0]!.id]], + _clearUrl: '', + _isClearHidden: true, + _isClosed: true, + order: 1, + hash: mockCustomListingPageHash, + }, + { + deep: 2, + description: 'Categories', + dynamic: 0, + format: FacetGroupFormat.Hierarchical, + key: FacetGroupKey.Categories, + type: 6, + values: [[mockFacets[1]!.id]], + _clearUrl: '', + _isClearHidden: true, + _isClosed: true, + order: 2, + hash: mockCustomListingPageHash, + }, + { + deep: 1, + description: 'Colors', + dynamic: 0, + format: FacetGroupFormat.Multiple, + key: FacetGroupKey.Colors, + type: 11, + values: [[mockFacets[2]!.id]], + _clearUrl: '', + _isClearHidden: true, + _isClosed: true, + order: 1, + hash: mockCustomListingPageHash, + }, + { + deep: 0, + description: 'SizesByCategory', + dynamic: 136301, + format: FacetGroupFormat.Multiple, + key: FacetGroupKey.SizesByCategory, + order: 5, + type: 24, + values: [['sizesbycategory_22_136301']], + _clearUrl: '', + _isClearHidden: true, + _isClosed: true, + hash: mockCustomListingPageHash, + }, + ], + filterSegments: [ + { + order: 0, + type: 6, + key: 'categories', + gender: 0, + value: 144307, + valueUpperBound: 0, + slug: 'women', + description: 'Women', + deep: 1, + parentId: 0, + fromQueryString: false, + negativeFilter: false, + facetId: '', + prefixValue: '', + }, + ], + redirectInformation: null, + gender: GenderCode.Woman, + didYouMean: [], + searchTerm: '', + facetsBaseUrl: '', + _sorts: [], + _clearUrl: '', + _isClearHidden: false, + genderName: '', + breadCrumbs: [], + name: null, + hash: mockCustomListingPageHash, + }, }, }, }; @@ -2225,6 +2405,46 @@ export const mockProductsListEntity = { genderName: 'women', }; +export const mockCustomListingPage = { + slug: mockCustomListingPageSlug, + subfolder: 'us', + config: { + pageIndex: 1, + }, + breadCrumbs: [], + facetsBaseUrl: '/en-pt/shopping', + facetGroups: mockFacetGroups, + filterSegments: [ + { + order: 0, + type: 6, + key: 'categories', + gender: 0, + value: 144307, + valueUpperBound: 0, + slug: 'women', + description: 'Women', + deep: 1, + parentId: 0, + fromQueryString: false, + negativeFilter: false, + prefixValue: '182569', + }, + ], + gender: 0, + genderName: 'Woman', + id: 120198, + name: 'New arrivals', + products: { + totalPages: 10, + totalItems: 193, + entries: [{ id: 123 }, { id: 321 }], + }, + searchTerm: null, + redirectInformation: null, + pageType: null, +}; + export const mockProductsListsEntity: Record = { [mockProductsListHash]: mockProductsListEntity, }; diff --git a/tests/__fixtures__/products/state.fixtures.mts b/tests/__fixtures__/products/state.fixtures.mts index ff69d69f1..7467389f4 100644 --- a/tests/__fixtures__/products/state.fixtures.mts +++ b/tests/__fixtures__/products/state.fixtures.mts @@ -6,14 +6,15 @@ import { import { mockState as brandsMockState } from '../brands/index.mjs'; import { mockBagItemEntity } from '../bags/bagItem.fixtures.mjs'; import { mockCategoriesState } from '../categories/index.mjs'; -import { mockMerchantId, mockProductId } from './ids.fixtures.mjs'; -import { mockProduct } from './products.fixtures.mjs'; -import { mockProductGroupingAdapted } from './productGrouping.fixtures.mjs'; -import { mockProductGroupingPropertiesAdapted } from './productGroupingProperties.fixtures.mjs'; import { + mockCustomListingPageHash, mockProductsListHash, mockProductsListNormalizedPayload, } from './productsLists.fixtures.mjs'; +import { mockMerchantId, mockProductId } from './ids.fixtures.mjs'; +import { mockProduct } from './products.fixtures.mjs'; +import { mockProductGroupingAdapted } from './productGrouping.fixtures.mjs'; +import { mockProductGroupingPropertiesAdapted } from './productGroupingProperties.fixtures.mjs'; import { mockRecentlyViewedState } from './recentlyViewed.fixtures.mjs'; import { mockRecommendedProductSetState } from './recommendedProductSet.fixtures.mjs'; import { mockRecommendedProductsState } from './recommendedProducts.fixtures.mjs'; @@ -125,6 +126,7 @@ export const mockProductsListsState = { }, }, }; + export const mockMeasurementsState = { measurements: { isLoading: { @@ -289,6 +291,26 @@ export const mockProductsState = { }, }; +export const mockCustomListingPageState = { + ...mockProductsState, + products: { + ...mockProductsState.products, + lists: { + error: { [mockCustomListingPageHash]: undefined }, + isHydrated: { + [mockCustomListingPageHash]: true, + }, + isLoading: { [mockCustomListingPageHash]: false }, + hash: mockCustomListingPageHash, + productListingFacets: { + isLoading: false, + error: null, + result: [], + }, + }, + }, +}; + export const mockProductsWithMultipleFacetGroupValuesState = { ...mockProductsState, entities: {