diff --git a/packages/peregrine/lib/talons/Breadcrumbs/useBreadcrumbs.js b/packages/peregrine/lib/talons/Breadcrumbs/useBreadcrumbs.js index eabaa454fc..ef6033cce4 100644 --- a/packages/peregrine/lib/talons/Breadcrumbs/useBreadcrumbs.js +++ b/packages/peregrine/lib/talons/Breadcrumbs/useBreadcrumbs.js @@ -43,8 +43,7 @@ export const useBreadcrumbs = props => { nextFetchPolicy: 'cache-first' }); - // Default to .html for when the query has not yet returned. - const categoryUrlSuffix = (data && data.category.url_suffix) || '.html'; + const categoryUrlSuffix = (data && data.category.url_suffix) || ''; // When we have breadcrumb data sort and normalize it for easy rendering. const normalizedData = useMemo(() => { diff --git a/packages/peregrine/lib/talons/CategoryList/useCategoryTile.js b/packages/peregrine/lib/talons/CategoryList/useCategoryTile.js index cf5c443e03..5f8133cea2 100644 --- a/packages/peregrine/lib/talons/CategoryList/useCategoryTile.js +++ b/packages/peregrine/lib/talons/CategoryList/useCategoryTile.js @@ -39,7 +39,7 @@ export const useCategoryTile = props => { const itemObject = useMemo( () => ({ name: item.name, - url: `/${item.url_key}${item.url_suffix}` + url: `/${item.url_key}${item.url_suffix || ''}` }), [item] ); diff --git a/packages/peregrine/lib/talons/Header/__tests__/__snapshots__/useStoreSwitcher.spec.js.snap b/packages/peregrine/lib/talons/Header/__tests__/__snapshots__/useStoreSwitcher.spec.js.snap index 5368949e38..ef8880ff4e 100644 --- a/packages/peregrine/lib/talons/Header/__tests__/__snapshots__/useStoreSwitcher.spec.js.snap +++ b/packages/peregrine/lib/talons/Header/__tests__/__snapshots__/useStoreSwitcher.spec.js.snap @@ -4,17 +4,53 @@ exports[`should return correct shape 1`] = ` Object { "availableStores": Map { "store1" => Object { + "category_url_suffix": null, "currency": "USD", "isCurrent": false, "locale": "locale1", + "product_url_suffix": null, "storeName": "Store 1", }, "store2" => Object { + "category_url_suffix": ".html", "currency": "EUR", "isCurrent": true, "locale": "locale2", + "product_url_suffix": ".html", "storeName": "Store 2", }, + "store3" => Object { + "category_url_suffix": null, + "currency": "EUR", + "isCurrent": false, + "locale": "locale3", + "product_url_suffix": ".htm", + "storeName": "Store 3", + }, + "store4" => Object { + "category_url_suffix": ".htm", + "currency": "EUR", + "isCurrent": false, + "locale": "locale4", + "product_url_suffix": null, + "storeName": "Store 4", + }, + "store5" => Object { + "category_url_suffix": "-abc1", + "currency": "EUR", + "isCurrent": false, + "locale": "locale5", + "product_url_suffix": ".htm.htm", + "storeName": "Store 5", + }, + "store6" => Object { + "category_url_suffix": ".some.some", + "currency": "EUR", + "isCurrent": false, + "locale": "locale6", + "product_url_suffix": "-123abc", + "storeName": "Store 6", + }, }, "currentStoreName": "Store 2", "handleSwitchStore": [Function], diff --git a/packages/peregrine/lib/talons/Header/__tests__/useStoreSwitcher.spec.js b/packages/peregrine/lib/talons/Header/__tests__/useStoreSwitcher.spec.js index 329865d4ae..440d53415a 100644 --- a/packages/peregrine/lib/talons/Header/__tests__/useStoreSwitcher.spec.js +++ b/packages/peregrine/lib/talons/Header/__tests__/useStoreSwitcher.spec.js @@ -1,21 +1,15 @@ import React from 'react'; -import { useHistory } from 'react-router-dom'; import createTestInstance from '@magento/peregrine/lib/util/createTestInstance'; import { useStoreSwitcher } from '../useStoreSwitcher'; import { mockSetItem } from '../../../util/simplePersistence'; +import { useQuery } from '@apollo/client'; jest.mock('../../../util/simplePersistence'); jest.mock('react-router-dom', () => ({ - useHistory: jest.fn(() => ({})) + useLocation: jest.fn(() => ({ pathname: '' })) })); -const history = { - go: jest.fn() -}; - -useHistory.mockImplementation(() => history); - jest.mock('@magento/peregrine', () => ({ ...jest.requireActual('@magento/peregrine'), Util: { @@ -28,31 +22,13 @@ jest.mock('@magento/peregrine', () => ({ })); jest.mock('@apollo/client', () => { - const useQuery = jest.fn().mockReturnValue({ - data: { - storeConfig: { - code: 'store2', - store_name: 'Store 2' - }, - availableStores: [ - { - code: 'store1', - locale: 'locale1', - store_name: 'Store 1', - default_display_currency_code: 'USD' - }, - { - code: 'store2', - locale: 'locale2', - store_name: 'Store 2', - default_display_currency_code: 'EUR' - } - ] - }, - error: null, - loading: false - }); - return { useQuery }; + const ApolloClient = jest.requireActual('@apollo/client'); + const useQuery = jest.fn(); + + return { + ...ApolloClient, + useQuery + }; }); jest.mock('@magento/peregrine/lib/hooks/useDropdown', () => ({ @@ -67,6 +43,7 @@ jest.mock('@magento/peregrine/lib/hooks/useDropdown', () => ({ const defaultProps = { queries: { getStoreConfigData: 'getStoreConfigData', + getUrlResolverData: 'getUrlResolverData', getAvailableStoresData: 'getAvailableStoresData' } }; @@ -91,6 +68,87 @@ const getTalonProps = props => { return { talonProps, tree, update }; }; +const storeConfigResponse = { + code: 'store2', + store_name: 'Store 2' +}; + +const categoryPageResponse = { + id: 1, + type: 'CATEGORY' +}; + +const productPageResponse = { + id: 1, + type: 'PRODUCT' +}; + +const availableStoresResponse = [ + { + code: 'store1', + locale: 'locale1', + store_name: 'Store 1', + default_display_currency_code: 'USD', + category_url_suffix: null, + product_url_suffix: null + }, + { + code: 'store2', + locale: 'locale2', + store_name: 'Store 2', + default_display_currency_code: 'EUR', + category_url_suffix: '.html', + product_url_suffix: '.html' + }, + { + code: 'store3', + locale: 'locale3', + store_name: 'Store 3', + default_display_currency_code: 'EUR', + category_url_suffix: null, + product_url_suffix: '.htm' + }, + { + code: 'store4', + locale: 'locale4', + store_name: 'Store 4', + default_display_currency_code: 'EUR', + category_url_suffix: '.htm', + product_url_suffix: null + }, + { + code: 'store5', + locale: 'locale5', + store_name: 'Store 5', + default_display_currency_code: 'EUR', + category_url_suffix: '-abc1', + product_url_suffix: '.htm.htm' + }, + { + code: 'store6', + locale: 'locale6', + store_name: 'Store 6', + default_display_currency_code: 'EUR', + category_url_suffix: '.some.some', + product_url_suffix: '-123abc' + } +]; + +beforeEach(() => { + useQuery.mockReset(); + useQuery.mockImplementation(() => { + return { + data: { + storeConfig: storeConfigResponse, + urlResolver: categoryPageResponse, + availableStores: availableStoresResponse + }, + error: null, + loading: false + }; + }); +}); + test('should return correct shape', () => { const { talonProps } = getTalonProps(defaultProps); @@ -98,6 +156,29 @@ test('should return correct shape', () => { }); describe('event handlers', () => { + useQuery.mockImplementation(() => { + return { + data: { + storeConfig: storeConfigResponse, + availableStores: [ + { + code: 'store1', + locale: 'locale1', + store_name: 'Store 1', + default_display_currency_code: 'USD' + }, + { + code: 'store2', + locale: 'locale2', + store_name: 'Store 2', + default_display_currency_code: 'EUR' + } + ] + }, + error: null, + loading: false + }; + }); const { talonProps } = getTalonProps(defaultProps); test('handleSwitchStore switches store view', () => { @@ -108,7 +189,6 @@ describe('event handlers', () => { ['store_view_code', 'store1'], ['store_view_currency', 'USD'] ]); - expect(history.go).toHaveBeenCalledTimes(1); }); test('handleSwitchStore does nothing when switching to not existing store', () => { @@ -116,71 +196,280 @@ describe('event handlers', () => { handleSwitchStore('store404'); expect(mockSetItem).toHaveBeenCalledTimes(0); - expect(history.go).toHaveBeenCalledTimes(0); }); }); -test('includes store code when option is enabled and no store code is present in URL', () => { - process.env.USE_STORE_CODE_IN_URL = 'true'; +describe('handleSwitchStore updates url with configured store code', () => { + test('includes store code when option is enabled and no store code is present in URL', () => { + process.env.USE_STORE_CODE_IN_URL = 'true'; - const originalLocation = window.location; - delete window.location; - window.location = { - pathname: '/', - assign: jest.fn() - }; + const originalLocation = window.location; + delete window.location; + window.location = { + pathname: '/', + assign: jest.fn() + }; - const { talonProps } = getTalonProps(defaultProps); - const { handleSwitchStore } = talonProps; + const { talonProps } = getTalonProps(defaultProps); + const { handleSwitchStore } = talonProps; - handleSwitchStore('store1'); + handleSwitchStore('store1'); - expect(window.location.assign).toBeCalledWith('/store1'); + expect(window.location.assign).toBeCalledWith('/store1'); - process.env.USE_STORE_CODE_IN_URL = 'false'; - window.location = originalLocation; -}); + process.env.USE_STORE_CODE_IN_URL = 'false'; + window.location = originalLocation; + }); -test('replaces current store code in URL with new store code', () => { - process.env.USE_STORE_CODE_IN_URL = 'true'; + test('replaces current store code in URL with a suffix, with new store code and empty suffix', () => { + process.env.USE_STORE_CODE_IN_URL = 'true'; - const { talonProps } = getTalonProps(defaultProps); - const { handleSwitchStore } = talonProps; + const { talonProps } = getTalonProps(defaultProps); + const { handleSwitchStore } = talonProps; - const originalLocation = window.location; - delete window.location; - window.location = { - pathname: '/store2/category-name.html', - assign: jest.fn() - }; + const originalLocation = window.location; + delete window.location; + window.location = { + pathname: '/store2/category-name.html', + assign: jest.fn() + }; + + handleSwitchStore('store1'); + + expect(window.location.assign).toBeCalledWith('/store1/category-name'); + + process.env.USE_STORE_CODE_IN_URL = 'false'; + window.location = originalLocation; + }); + + test('adds store code to url when not present but store code in url enabled', () => { + process.env.USE_STORE_CODE_IN_URL = 'true'; + + const { talonProps } = getTalonProps(defaultProps); + const { handleSwitchStore } = talonProps; + + const originalLocation = window.location; + delete window.location; + window.location = { + pathname: '/category/category-name.html', + assign: jest.fn() + }; + + handleSwitchStore('store2'); + + expect(window.location.assign).toBeCalledWith( + '/store2/category/category-name.html' + ); + + process.env.USE_STORE_CODE_IN_URL = 'false'; + window.location = originalLocation; + }); + + test('displays correct category url suffix in url, with store code in url enabled', () => { + process.env.USE_STORE_CODE_IN_URL = 'true'; + + const { talonProps } = getTalonProps(defaultProps); + const { handleSwitchStore } = talonProps; + + const originalLocation = window.location; + delete window.location; + window.location = { + pathname: '/category/category-name.html', + assign: jest.fn() + }; + + handleSwitchStore('store1'); + + // .html => null + expect(window.location.assign).toBeCalledWith( + '/store1/category/category-name' + ); + + handleSwitchStore('store4'); + + // null => .htm + expect(window.location.assign).toBeCalledWith( + '/store4/category/category-name.htm' + ); + + handleSwitchStore('store5'); + + // .htm => -abc1 + expect(window.location.assign).toBeCalledWith( + '/store5/category/category-name-abc1' + ); + + handleSwitchStore('store6'); + + // -abc1 => .some.some + expect(window.location.assign).toBeCalledWith( + '/store6/category/category-name.some.some' + ); + + handleSwitchStore('store1'); + + // .some.some => null + expect(window.location.assign).toBeCalledWith( + '/store1/category/category-name' + ); + + process.env.USE_STORE_CODE_IN_URL = 'false'; + window.location = originalLocation; + }); + + test('displays correct product url suffix in url, with store code in url enabled', () => { + process.env.USE_STORE_CODE_IN_URL = 'true'; + useQuery.mockImplementation(() => { + return { + data: { + storeConfig: storeConfigResponse, + urlResolver: productPageResponse, + availableStores: availableStoresResponse + } + }; + }); + + const { talonProps } = getTalonProps(defaultProps); + const { handleSwitchStore } = talonProps; + + const originalLocation = window.location; + delete window.location; + window.location = { + pathname: '/product.html', + assign: jest.fn() + }; + + handleSwitchStore('store1'); + + // .html => null + expect(window.location.assign).toBeCalledWith('/store1/product'); + + handleSwitchStore('store3'); - handleSwitchStore('store1'); + // null => .htm + expect(window.location.assign).toBeCalledWith('/store3/product.htm'); - expect(window.location.assign).toBeCalledWith('/store1/category-name.html'); + handleSwitchStore('store6'); - process.env.USE_STORE_CODE_IN_URL = 'false'; - window.location = originalLocation; + // .htm => -123abc + expect(window.location.assign).toBeCalledWith('/store6/product-123abc'); + + handleSwitchStore('store5'); + + // -123abc => .htm.htm + expect(window.location.assign).toBeCalledWith( + '/store5/product.htm.htm' + ); + + handleSwitchStore('store1'); + + // .some.some => null + expect(window.location.assign).toBeCalledWith('/store1/product'); + + process.env.USE_STORE_CODE_IN_URL = 'false'; + window.location = originalLocation; + }); }); -test('adds store code to url when not present but store code in url enabled', () => { - process.env.USE_STORE_CODE_IN_URL = 'true'; +describe('handleSwitchStore updates url with store code not configured', () => { + test('displays correct category url suffix in url, with store code in url disabled', () => { + process.env.USE_STORE_CODE_IN_URL = 'false'; - const { talonProps } = getTalonProps(defaultProps); - const { handleSwitchStore } = talonProps; + const { talonProps } = getTalonProps(defaultProps); + const { handleSwitchStore } = talonProps; - const originalLocation = window.location; - delete window.location; - window.location = { - pathname: '/category/category-name.html', - assign: jest.fn() - }; + const originalLocation = window.location; + delete window.location; + window.location = { + pathname: '/category/category-name.html', + assign: jest.fn() + }; + + handleSwitchStore('store1'); + + // .html => null + expect(window.location.assign).toBeCalledWith( + '/category/category-name' + ); + + handleSwitchStore('store4'); - handleSwitchStore('store1'); + // null => .htm + expect(window.location.assign).toBeCalledWith( + '/category/category-name.htm' + ); - expect(window.location.assign).toBeCalledWith( - '/store1/category/category-name.html' - ); + handleSwitchStore('store5'); - process.env.USE_STORE_CODE_IN_URL = 'false'; - window.location = originalLocation; + // .htm => -abc1 + expect(window.location.assign).toBeCalledWith( + '/category/category-name-abc1' + ); + + handleSwitchStore('store6'); + + // -abc1 => .some.some + expect(window.location.assign).toBeCalledWith( + '/category/category-name.some.some' + ); + + handleSwitchStore('store1'); + + // .some.some => null + expect(window.location.assign).toBeCalledWith( + '/category/category-name' + ); + + window.location = originalLocation; + }); + + test('displays correct product url suffix in url, with store code in url disabled', () => { + process.env.USE_STORE_CODE_IN_URL = 'false'; + useQuery.mockImplementation(() => { + return { + data: { + storeConfig: storeConfigResponse, + urlResolver: productPageResponse, + availableStores: availableStoresResponse + } + }; + }); + + const { talonProps } = getTalonProps(defaultProps); + const { handleSwitchStore } = talonProps; + + const originalLocation = window.location; + delete window.location; + window.location = { + pathname: '/product.html', + assign: jest.fn() + }; + + handleSwitchStore('store1'); + + // .html => null + expect(window.location.assign).toBeCalledWith('/product'); + + handleSwitchStore('store3'); + + // null => .htm + expect(window.location.assign).toBeCalledWith('/product.htm'); + + handleSwitchStore('store6'); + + // .htm => -123abc + expect(window.location.assign).toBeCalledWith('/product-123abc'); + + handleSwitchStore('store5'); + + // -123abc => .htm.htm + expect(window.location.assign).toBeCalledWith('/product.htm.htm'); + + handleSwitchStore('store1'); + + // .htm.htm => null + expect(window.location.assign).toBeCalledWith('/product'); + + window.location = originalLocation; + }); }); diff --git a/packages/venia-ui/lib/components/Header/storeSwitcher.gql.js b/packages/peregrine/lib/talons/Header/storeSwitcher.gql.js similarity index 55% rename from packages/venia-ui/lib/components/Header/storeSwitcher.gql.js rename to packages/peregrine/lib/talons/Header/storeSwitcher.gql.js index 9e4bebf557..ac49c7c115 100644 --- a/packages/venia-ui/lib/components/Header/storeSwitcher.gql.js +++ b/packages/peregrine/lib/talons/Header/storeSwitcher.gql.js @@ -10,21 +10,31 @@ export const GET_STORE_CONFIG_DATA = gql` } `; +export const GET_URL_RESOLVER_DATA = gql` + query getUrlResolverData($url: String!) { + urlResolver(url: $url) { + id + type + } + } +`; + export const GET_AVAILABLE_STORES_DATA = gql` query getAvailableStoresData { availableStores { + category_url_suffix code default_display_currency_code id locale + product_url_suffix store_name } } `; export default { - queries: { - getStoreConfigData: GET_STORE_CONFIG_DATA, - getAvailableStoresData: GET_AVAILABLE_STORES_DATA - } + getStoreConfigData: GET_STORE_CONFIG_DATA, + getUrlResolverData: GET_URL_RESOLVER_DATA, + getAvailableStoresData: GET_AVAILABLE_STORES_DATA }; diff --git a/packages/peregrine/lib/talons/Header/useStoreSwitcher.js b/packages/peregrine/lib/talons/Header/useStoreSwitcher.js index 8a089f90f9..1472462f21 100644 --- a/packages/peregrine/lib/talons/Header/useStoreSwitcher.js +++ b/packages/peregrine/lib/talons/Header/useStoreSwitcher.js @@ -1,8 +1,10 @@ import { useQuery } from '@apollo/client'; import { useCallback, useMemo } from 'react'; -import { useHistory } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { useDropdown } from '@magento/peregrine/lib/hooks/useDropdown'; import { BrowserPersistence } from '@magento/peregrine/lib/util'; +import mergeOperations from '../../util/shallowMerge'; +import DEFAULT_OPERATIONS from './storeSwitcher.gql'; const storage = new BrowserPersistence(); @@ -11,14 +13,23 @@ const mapAvailableOptions = (config, stores) => { return stores.reduce((map, store) => { const { + category_url_suffix, code, default_display_currency_code: currency, locale, + product_url_suffix, store_name: storeName } = store; const isCurrent = code === configCode; - const option = { currency, isCurrent, locale, storeName }; + const option = { + category_url_suffix, + currency, + isCurrent, + locale, + product_url_suffix, + storeName + }; return map.set(code, option); }, new Map()); @@ -38,10 +49,14 @@ const mapAvailableOptions = (config, stores) => { * @returns {Function} talonProps.handleSwitchStore - A function for handling when the menu item is clicked. */ -export const useStoreSwitcher = props => { - const { queries } = props; - const { getStoreConfigData, getAvailableStoresData } = queries; - const history = useHistory(); +export const useStoreSwitcher = (props = {}) => { + const operations = mergeOperations(DEFAULT_OPERATIONS, props.operations); + const { + getStoreConfigData, + getUrlResolverData, + getAvailableStoresData + } = operations; + const { pathname } = useLocation(); const { elementRef: storeMenuRef, expanded: storeMenuIsOpen, @@ -54,6 +69,11 @@ export const useStoreSwitcher = props => { nextFetchPolicy: 'cache-first' }); + const { data: urlResolverData } = useQuery(getUrlResolverData, { + fetchPolicy: 'cache-first', + variables: { url: pathname } + }); + const { data: availableStoresData } = useQuery(getAvailableStoresData, { fetchPolicy: 'cache-and-network', nextFetchPolicy: 'cache-first' @@ -65,6 +85,18 @@ export const useStoreSwitcher = props => { } }, [storeConfigData]); + const currentStoreCode = useMemo(() => { + if (storeConfigData) { + return storeConfigData.storeConfig.code; + } + }, [storeConfigData]); + + const pageType = useMemo(() => { + if (urlResolverData && urlResolverData.urlResolver) { + return urlResolverData.urlResolver.type; + } + }, [urlResolverData]); + const availableStores = useMemo(() => { return ( storeConfigData && @@ -76,12 +108,51 @@ export const useStoreSwitcher = props => { ); }, [storeConfigData, availableStoresData]); + // Get pathname with suffix based on page type + const getPathname = useCallback( + storeCode => { + // Use window.location.pathname to get the path with the store view code + // pathname from useLocation() does not include the store view code + const pathname = window.location.pathname; + + if (pageType === 'CATEGORY') { + const currentSuffix = + availableStores.get(currentStoreCode).category_url_suffix || + ''; + const newSuffix = + availableStores.get(storeCode).category_url_suffix || ''; + + return currentSuffix + ? pathname.replace(currentSuffix, newSuffix) + : `${pathname}${newSuffix}`; + } + if (pageType === 'PRODUCT') { + const currentSuffix = + availableStores.get(currentStoreCode).product_url_suffix || + ''; + const newSuffix = + availableStores.get(storeCode).product_url_suffix || ''; + + return currentSuffix + ? pathname.replace(currentSuffix, newSuffix) + : `${pathname}${newSuffix}`; + } + + // search.html ...etc + return pathname; + }, + [availableStores, currentStoreCode, pageType] + ); + const handleSwitchStore = useCallback( // Change store view code and currency to be used in Apollo link request headers storeCode => { // Do nothing when store view is not present in available stores if (!availableStores.has(storeCode)) return; + const pathName = getPathname(storeCode); + const params = window.location.search || ''; + storage.setItem('store_view_code', storeCode); storage.setItem( 'store_view_currency', @@ -92,9 +163,6 @@ export const useStoreSwitcher = props => { // In this block we use `window.location.assign` to work around the // static React Router basename, which is changed on initialization. if (process.env.USE_STORE_CODE_IN_URL === 'true') { - const pathName = window.location.pathname; - const params = window.location.search || ''; - // Check to see if we're on a page outside of the homepage if (pathName !== '' && pathName !== '/') { const [, pathStoreCode] = pathName.split('/'); @@ -123,10 +191,10 @@ export const useStoreSwitcher = props => { } else { // Refresh the page to re-trigger the queries once code/currency // are saved in local storage. - history.go(0); + window.location.assign(`${pathName}${params}`); } }, - [history, availableStores] + [availableStores, getPathname] ); const handleTriggerClick = useCallback(() => { diff --git a/packages/peregrine/lib/talons/OrderHistoryPage/__tests__/__snapshots__/orderHistoryContext.spec.js.snap b/packages/peregrine/lib/talons/OrderHistoryPage/__tests__/__snapshots__/orderHistoryContext.spec.js.snap index 8f1f83c8cd..06aa36caab 100644 --- a/packages/peregrine/lib/talons/OrderHistoryPage/__tests__/__snapshots__/orderHistoryContext.spec.js.snap +++ b/packages/peregrine/lib/talons/OrderHistoryPage/__tests__/__snapshots__/orderHistoryContext.spec.js.snap @@ -2,9 +2,9 @@ exports[`renders children 1`] = `"Context Provider Children"`; -exports[`returns default value for suffix 1`] = ` +exports[`returns empty string if no suffix found in gql 1`] = ` Object { - "productURLSuffix": ".html", + "productURLSuffix": "", } `; diff --git a/packages/peregrine/lib/talons/OrderHistoryPage/__tests__/orderHistoryContext.spec.js b/packages/peregrine/lib/talons/OrderHistoryPage/__tests__/orderHistoryContext.spec.js index e4be09058e..b3c926f64d 100644 --- a/packages/peregrine/lib/talons/OrderHistoryPage/__tests__/orderHistoryContext.spec.js +++ b/packages/peregrine/lib/talons/OrderHistoryPage/__tests__/orderHistoryContext.spec.js @@ -34,7 +34,7 @@ test('renders children', () => { expect(tree.toJSON()).toMatchSnapshot(); }); -test('returns default value for suffix', () => { +test('returns empty string if no suffix found in gql', () => { createTestInstance( diff --git a/packages/peregrine/lib/talons/OrderHistoryPage/orderHistoryContext.js b/packages/peregrine/lib/talons/OrderHistoryPage/orderHistoryContext.js index b75069ba35..fcf7b5660f 100644 --- a/packages/peregrine/lib/talons/OrderHistoryPage/orderHistoryContext.js +++ b/packages/peregrine/lib/talons/OrderHistoryPage/orderHistoryContext.js @@ -16,9 +16,7 @@ const OrderHistoryContextProvider = props => { const storeConfig = useMemo(() => { return { - productURLSuffix: data - ? data.storeConfig.product_url_suffix - : '.html' + productURLSuffix: data ? data.storeConfig.product_url_suffix : '' }; }, [data]); diff --git a/packages/peregrine/lib/talons/RootComponents/Product/__tests__/useProduct.spec.js b/packages/peregrine/lib/talons/RootComponents/Product/__tests__/useProduct.spec.js index 92838c36c4..d0499cf6dc 100644 --- a/packages/peregrine/lib/talons/RootComponents/Product/__tests__/useProduct.spec.js +++ b/packages/peregrine/lib/talons/RootComponents/Product/__tests__/useProduct.spec.js @@ -48,21 +48,33 @@ const Component = props => { }; const props = { - mapProduct: jest.fn(product => product), - urlKey: 'unit_test' + mapProduct: jest.fn(product => product) +}; + +const storeConfigResponse = { + id: 1, + product_url_suffix: null }; test('it returns the proper shape', () => { // Arrange. - useQuery.mockReturnValueOnce({ - data: { - products: { - items: [] - } - }, - error: null, - loading: false - }); + useQuery + .mockReturnValueOnce({ + data: { + storeConfig: storeConfigResponse + }, + error: null, + loading: false + }) + .mockReturnValueOnce({ + data: { + products: { + items: [] + } + }, + error: null, + loading: false + }); // Act. createTestInstance(); @@ -93,15 +105,23 @@ test('product is null when data is falsy', () => { test('product is null when items array is empty', () => { // Arrange. - useQuery.mockReturnValueOnce({ - data: { - products: { - items: [] - } - }, - error: null, - loading: false - }); + useQuery + .mockReturnValueOnce({ + data: { + storeConfig: storeConfigResponse + }, + error: null, + loading: false + }) + .mockReturnValueOnce({ + data: { + products: { + items: [] + } + }, + error: null, + loading: false + }); // Act. createTestInstance(); @@ -114,15 +134,23 @@ test('product is null when items array is empty', () => { test('product is null when items array doesnt contain requested urlKey', () => { // Arrange. - useQuery.mockReturnValueOnce({ - data: { - products: { - items: [{ name: 'INVALID', url_key: 'INVALID' }] - } - }, - error: null, - loading: false - }); + useQuery + .mockReturnValueOnce({ + data: { + storeConfig: storeConfigResponse + }, + error: null, + loading: false + }) + .mockReturnValueOnce({ + data: { + products: { + items: [{ name: 'INVALID', url_key: 'INVALID' }] + } + }, + error: null, + loading: false + }); // Act. createTestInstance(); @@ -135,18 +163,104 @@ test('product is null when items array doesnt contain requested urlKey', () => { test('product is correct when included in items array', () => { // Arrange. - useQuery.mockReturnValueOnce({ - data: { - products: { - items: [ - { name: 'INVALID', url_key: 'INVALID' }, - { name: 'VALID', url_key: props.urlKey } - ] - } - }, - error: null, - loading: false - }); + useQuery + .mockReturnValueOnce({ + data: { + storeConfig: storeConfigResponse + }, + error: null, + loading: false + }) + .mockReturnValueOnce({ + data: { + products: { + items: [ + { name: 'INVALID', url_key: 'INVALID' }, + { name: 'VALID', url_key: 'unit_test' } + ] + } + }, + error: null, + loading: false + }); + + const originalLocation = window.location; + delete window.location; + window.location = { + pathname: '/unit_test' + }; + + // Act. + createTestInstance(); + + // Assert. + const talonProps = log.mock.calls[0][0]; + const { product } = talonProps; + expect(product).toEqual({ name: 'VALID', url_key: 'unit_test' }); + window.location = originalLocation; +}); + +test('product is correct when product url suffix is configured', () => { + // Arrange. + useQuery + .mockReturnValueOnce({ + data: { + storeConfig: { id: 1, product_url_suffix: '.html' } + }, + error: null, + loading: false + }) + .mockReturnValueOnce({ + data: { + products: { + items: [{ name: 'VALID', url_key: 'unit_test' }] + } + }, + error: null, + loading: false + }); + + const originalLocation = window.location; + delete window.location; + window.location = { + pathname: '/unit_test.html' + }; + + // Act. + createTestInstance(); + + // Assert. + const talonProps = log.mock.calls[0][0]; + const { product } = talonProps; + expect(product).toEqual({ name: 'VALID', url_key: 'unit_test' }); + window.location = originalLocation; +}); + +test('product is correct when product url suffix is configured with no period', () => { + // Arrange. + useQuery + .mockReturnValueOnce({ + data: { + storeConfig: { id: 1, product_url_suffix: 'noperiod' } + }, + error: null, + loading: false + }) + .mockReturnValueOnce({ + data: { + products: { + items: [{ name: 'VALID', url_key: 'unit_test' }] + } + }, + error: null, + loading: false + }); + + const originalLocation = window.location; + delete window.location; + window.location = { + pathname: '/unit_testnoperiod' + }; // Act. createTestInstance(); @@ -154,5 +268,6 @@ test('product is correct when included in items array', () => { // Assert. const talonProps = log.mock.calls[0][0]; const { product } = talonProps; - expect(product).toEqual({ name: 'VALID', url_key: props.urlKey }); + expect(product).toEqual({ name: 'VALID', url_key: 'unit_test' }); + window.location = originalLocation; }); diff --git a/packages/peregrine/lib/talons/RootComponents/Product/product.gql.js b/packages/peregrine/lib/talons/RootComponents/Product/product.gql.js index 8d7fe8eff3..90ab250ef3 100644 --- a/packages/peregrine/lib/talons/RootComponents/Product/product.gql.js +++ b/packages/peregrine/lib/talons/RootComponents/Product/product.gql.js @@ -2,6 +2,15 @@ import { gql } from '@apollo/client'; import { ProductDetailsFragment } from './productDetailFragment.gql'; +export const GET_STORE_CONFIG_DATA = gql` + query getStoreConfigData { + storeConfig { + id + product_url_suffix + } + } +`; + export const GET_PRODUCT_DETAIL_QUERY = gql` query getProductDetailForProductPage($urlKey: String!) { products(filter: { url_key: { eq: $urlKey } }) { @@ -15,5 +24,6 @@ export const GET_PRODUCT_DETAIL_QUERY = gql` `; export default { + getStoreConfigData: GET_STORE_CONFIG_DATA, getProductDetailQuery: GET_PRODUCT_DETAIL_QUERY }; diff --git a/packages/peregrine/lib/talons/RootComponents/Product/useProduct.js b/packages/peregrine/lib/talons/RootComponents/Product/useProduct.js index 5294b6c4e8..4d2dd0ca96 100644 --- a/packages/peregrine/lib/talons/RootComponents/Product/useProduct.js +++ b/packages/peregrine/lib/talons/RootComponents/Product/useProduct.js @@ -13,8 +13,8 @@ import DEFAULT_OPERATIONS from './product.gql'; * * @param {object} props * @param {Function} props.mapProduct - A function for updating products to the proper shape. + * @param {GraphQLAST} props.queries.getStoreConfigData - Fetches storeConfig product url suffix using a server query * @param {GraphQLAST} props.queries.getProductQuery - Fetches product using a server query - * @param {String} props.urlKey - The url_key of this product. * * @returns {object} result * @returns {Bool} result.error - Indicates a network error occurred. @@ -22,10 +22,10 @@ import DEFAULT_OPERATIONS from './product.gql'; * @returns {Bool} result.product - The product's details. */ export const useProduct = props => { - const { mapProduct, urlKey } = props; + const { mapProduct } = props; const operations = mergeOperations(DEFAULT_OPERATIONS, props.operations); - const { getProductDetailQuery } = operations; + const { getStoreConfigData, getProductDetailQuery } = operations; const [ , @@ -34,9 +34,26 @@ export const useProduct = props => { } ] = useAppContext(); + const { data: storeConfigData } = useQuery(getStoreConfigData, { + fetchPolicy: 'cache-and-network', + nextFetchPolicy: 'cache-first' + }); + + const productUrlSuffix = useMemo(() => { + if (storeConfigData) { + return storeConfigData.storeConfig.product_url_suffix; + } + }, [storeConfigData]); + + const pathname = window.location.pathname.split('/').pop(); + const urlKey = productUrlSuffix + ? pathname.replace(productUrlSuffix, '') + : pathname; + const { error, loading, data } = useQuery(getProductDetailQuery, { fetchPolicy: 'cache-and-network', nextFetchPolicy: 'cache-first', + skip: !storeConfigData, variables: { urlKey } diff --git a/packages/venia-ui/lib/RootComponents/Product/product.js b/packages/venia-ui/lib/RootComponents/Product/product.js index 16a56ad413..ef16b73d78 100644 --- a/packages/venia-ui/lib/RootComponents/Product/product.js +++ b/packages/venia-ui/lib/RootComponents/Product/product.js @@ -6,7 +6,6 @@ import ErrorView from '@magento/venia-ui/lib/components/ErrorView'; import { Title, Meta } from '@magento/venia-ui/lib/components/Head'; import { fullPageLoadingIndicator } from '@magento/venia-ui/lib/components/LoadingIndicator'; import ProductFullDetail from '@magento/venia-ui/lib/components/ProductFullDetail'; -import getUrlKey from '@magento/venia-ui/lib/util/getUrlKey'; import mapProduct from '@magento/venia-ui/lib/util/mapProduct'; /* @@ -19,8 +18,7 @@ import mapProduct from '@magento/venia-ui/lib/util/mapProduct'; const Product = () => { const talonProps = useProduct({ - mapProduct, - urlKey: getUrlKey() + mapProduct }); const { error, loading, product } = talonProps; diff --git a/packages/venia-ui/lib/components/CartPage/ProductListing/product.js b/packages/venia-ui/lib/components/CartPage/ProductListing/product.js index c47de44d81..f39448a772 100644 --- a/packages/venia-ui/lib/components/CartPage/ProductListing/product.js +++ b/packages/venia-ui/lib/components/CartPage/ProductListing/product.js @@ -78,10 +78,10 @@ const Product = props => { /> ) : null; - const itemLink = useMemo(() => resourceUrl(`/${urlKey}${urlSuffix}`), [ - urlKey, - urlSuffix - ]); + const itemLink = useMemo( + () => resourceUrl(`/${urlKey}${urlSuffix || ''}`), + [urlKey, urlSuffix] + ); const stockStatusMessage = stockStatus === 'OUT_OF_STOCK' diff --git a/packages/venia-ui/lib/components/CategoryTree/categoryLeaf.js b/packages/venia-ui/lib/components/CategoryTree/categoryLeaf.js index ffc2d3945a..26cbff17c7 100644 --- a/packages/venia-ui/lib/components/CategoryTree/categoryLeaf.js +++ b/packages/venia-ui/lib/components/CategoryTree/categoryLeaf.js @@ -12,7 +12,7 @@ const Leaf = props => { const { name, url_path, url_suffix, children } = category; const classes = mergeClasses(defaultClasses, props.classes); const { handleClick } = useCategoryLeaf({ onNavigate }); - const destination = resourceUrl(`/${url_path}${url_suffix}`); + const destination = resourceUrl(`/${url_path}${url_suffix || ''}`); const leafLabel = children && children.length ? ( diff --git a/packages/venia-ui/lib/components/Gallery/item.js b/packages/venia-ui/lib/components/Gallery/item.js index 93e5adf792..8758911d66 100644 --- a/packages/venia-ui/lib/components/Gallery/item.js +++ b/packages/venia-ui/lib/components/Gallery/item.js @@ -46,7 +46,7 @@ const GalleryItem = props => { } const { name, price, small_image, url_key, url_suffix } = item; - const productLink = resourceUrl(`/${url_key}${url_suffix}`); + const productLink = resourceUrl(`/${url_key}${url_suffix || ''}`); return (
diff --git a/packages/venia-ui/lib/components/Header/storeSwitcher.js b/packages/venia-ui/lib/components/Header/storeSwitcher.js index 186715e284..1b87473510 100644 --- a/packages/venia-ui/lib/components/Header/storeSwitcher.js +++ b/packages/venia-ui/lib/components/Header/storeSwitcher.js @@ -7,14 +7,9 @@ import { useStoreSwitcher } from '@magento/peregrine/lib/talons/Header/useStoreS import { mergeClasses } from '../../classify'; import defaultClasses from './storeSwitcher.css'; import SwitcherItem from './switcherItem'; -import storeSwitcherOperations from './storeSwitcher.gql'; import Icon from '../Icon'; const StoreSwitcher = props => { - const talonProps = useStoreSwitcher({ - ...storeSwitcherOperations - }); - const { handleSwitchStore, currentStoreName, @@ -23,7 +18,7 @@ const StoreSwitcher = props => { storeMenuTriggerRef, storeMenuIsOpen, handleTriggerClick - } = talonProps; + } = useStoreSwitcher(); const classes = mergeClasses(defaultClasses, props.classes); const menuClassName = storeMenuIsOpen ? classes.menu_open : classes.menu; diff --git a/packages/venia-ui/lib/components/MiniCart/ProductList/item.js b/packages/venia-ui/lib/components/MiniCart/ProductList/item.js index f93c27fcea..1e911b6680 100644 --- a/packages/venia-ui/lib/components/MiniCart/ProductList/item.js +++ b/packages/venia-ui/lib/components/MiniCart/ProductList/item.js @@ -31,7 +31,7 @@ const Item = props => { const { formatMessage } = useIntl(); const classes = mergeClasses(defaultClasses, propClasses); const itemLink = useMemo( - () => resourceUrl(`/${product.url_key}${product.url_suffix}`), + () => resourceUrl(`/${product.url_key}${product.url_suffix || ''}`), [product.url_key, product.url_suffix] ); const stockStatusText = diff --git a/packages/venia-ui/lib/components/SearchBar/suggestedProduct.js b/packages/venia-ui/lib/components/SearchBar/suggestedProduct.js index 90084c02cf..3eac7c07c5 100644 --- a/packages/venia-ui/lib/components/SearchBar/suggestedProduct.js +++ b/packages/venia-ui/lib/components/SearchBar/suggestedProduct.js @@ -19,7 +19,7 @@ const SuggestedProduct = props => { } }, [onNavigate]); - const uri = useMemo(() => resourceUrl(`/${url_key}${url_suffix}`), [ + const uri = useMemo(() => resourceUrl(`/${url_key}${url_suffix || ''}`), [ url_key, url_suffix ]); diff --git a/packages/venia-ui/lib/drivers/adapter.js b/packages/venia-ui/lib/drivers/adapter.js index 35320314d8..c6742c7736 100644 --- a/packages/venia-ui/lib/drivers/adapter.js +++ b/packages/venia-ui/lib/drivers/adapter.js @@ -49,8 +49,10 @@ const VeniaAdapter = props => { const cache = apollo.cache || preInstantiatedCache; const link = apollo.link || VeniaAdapter.apolloLink(apiBase); + const storeCode = storage.getItem('store_view_code') || 'default'; const persistor = new CachePersistor({ + key: `apollo-cache-persist-${storeCode}`, cache, storage: window.localStorage, debug: process.env.NODE_ENV === 'development' diff --git a/packages/venia-ui/lib/util/__tests__/getUrlKey.spec.js b/packages/venia-ui/lib/util/__tests__/getUrlKey.spec.js deleted file mode 100644 index 4ea929e23b..0000000000 --- a/packages/venia-ui/lib/util/__tests__/getUrlKey.spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import getUrlKey from '../getUrlKey'; - -test('returns the no-extension basename from a set of URL properties', () => { - const a = document.createElement('a'); - a.setAttribute('href', 'http://example.com/this-is-url-key.html'); - expect(getUrlKey(a)).toBe('this-is-url-key'); -}); - -test('returns the no-extension basename from a set of URL properties with trailing /', () => { - const a = document.createElement('a'); - a.setAttribute('href', 'http://example.com/this-is-url-key.html/'); - expect(getUrlKey(a)).toBe('this-is-url-key'); -}); - -test('gets the last path segment', () => { - const uri = new URL( - 'https://user:pass@example.com:8000/baseDir/path2/lastSegment.html?some=query' - ); - expect(getUrlKey(uri)).toBe('lastSegment'); -}); - -test('gets the last path segment with trailing /', () => { - const uri = new URL( - 'https://user:pass@example.com:8000/baseDir/path2/lastSegment.html/?some=query' - ); - expect(getUrlKey(uri)).toBe('lastSegment'); -}); - -test('uses the window.location object if no argument', () => { - // should match pathname except with no leading slash - expect(getUrlKey()).toMatch(window.location.pathname.replace(/^\//, '')); -}); diff --git a/packages/venia-ui/lib/util/getUrlKey.js b/packages/venia-ui/lib/util/getUrlKey.js deleted file mode 100644 index e38f86382f..0000000000 --- a/packages/venia-ui/lib/util/getUrlKey.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Extract the basename from a URL object, which corresponds to the `url_key` - * property of a Magento 2 GraphQL entity. Mostly used to get the url_key - * from a recently navigated-to URL. - * - * @param {URL} url - * @returns {string} A string for use as the `url_key` in a GraphQL query. - */ -export default function getUrlKey(url = window.location) { - // The URL key is the last path segment. - // TODO: this may be configurable, but Magento SEO urls appear to always - // append `.html`, which is not part of the URL key. So strip it. - const pathname = url.pathname.replace(/\.html\/?$/, ''); - return pathname.slice(pathname.lastIndexOf('/') + 1); -}