diff --git a/packages/template-retail-react-app/app/components/_app-config/index.jsx b/packages/template-retail-react-app/app/components/_app-config/index.jsx index 0f018b613c..7bfd0a8cd7 100644 --- a/packages/template-retail-react-app/app/components/_app-config/index.jsx +++ b/packages/template-retail-react-app/app/components/_app-config/index.jsx @@ -19,9 +19,11 @@ import { CustomerProductListsProvider, CustomerProvider } from '../../commerce-api/contexts' +import {MultiSiteProvider} from '../../contexts' import {resolveSiteFromUrl} from '../../utils/site-utils' import {resolveLocaleFromUrl} from '../../utils/utils' import {getConfig} from 'pwa-kit-runtime/utils/ssr-config' +import {createUrlTemplate} from '../../utils/url' /** * Use the AppConfig component to inject extra arguments into the getProps @@ -36,15 +38,17 @@ const AppConfig = ({children, locals = {}}) => { const [customer, setCustomer] = useState(null) return ( - - - - - {children} - - - - + + + + + + {children} + + + + + ) } @@ -66,13 +70,19 @@ AppConfig.restore = (locals = {}) => { apiConfig.parameters.siteId = site.id locals.api = new CommerceAPI({...apiConfig, locale: locale.id, currency}) + locals.buildUrl = createUrlTemplate(appConfig, site.alias || site.id, locale.id) + locals.site = site + locals.locale = locale.id } AppConfig.freeze = () => undefined AppConfig.extraGetPropsArgs = (locals = {}) => { return { - api: locals.api + api: locals.api, + buildUrl: locals.buildUrl, + site: locals.site, + locale: locals.locale } } diff --git a/packages/template-retail-react-app/app/components/_app/index.jsx b/packages/template-retail-react-app/app/components/_app/index.jsx index 24d14d7282..2fdf569943 100644 --- a/packages/template-retail-react-app/app/components/_app/index.jsx +++ b/packages/template-retail-react-app/app/components/_app/index.jsx @@ -35,8 +35,6 @@ import useShopper from '../../commerce-api/hooks/useShopper' import useCustomer from '../../commerce-api/hooks/useCustomer' import {AuthModal, useAuthModal} from '../../hooks/use-auth-modal' import {AddToCartModalProvider} from '../../hooks/use-add-to-cart-modal' -import useSite from '../../hooks/use-site' -import useLocale from '../../hooks/use-locale' import useWishlist from '../../hooks/use-wishlist' // Localization @@ -44,12 +42,12 @@ import {IntlProvider} from 'react-intl' // Others import {watchOnlineStatus, flatten} from '../../utils/utils' -import {homeUrlBuilder, getPathWithLocale, buildPathWithUrlConfig} from '../../utils/url' import {getTargetLocale, fetchTranslations} from '../../utils/locale' import {DEFAULT_SITE_TITLE, HOME_HREF, THEME_COLOR} from '../../constants' import Seo from '../seo' import {resolveSiteFromUrl} from '../../utils/site-utils' +import useMultiSite from '../../hooks/use-multi-site' const DEFAULT_NAV_DEPTH = 3 const DEFAULT_ROOT_CATEGORY = 'root' @@ -63,18 +61,11 @@ const App = (props) => { const location = useLocation() const authModal = useAuthModal() const customer = useCustomer() - - const site = useSite() - const locale = useLocale() + const {site, locale, buildUrl} = useMultiSite() const [isOnline, setIsOnline] = useState(true) const styles = useStyleConfig('App') - const configValues = { - locale: locale.alias || locale.id, - site: site.alias || site.id - } - const {isOpen, onOpen, onClose} = useDisclosure() // Used to conditionally render header/footer for checkout page @@ -115,7 +106,8 @@ const App = (props) => { const onLogoClick = () => { // Goto the home page. - const path = homeUrlBuilder(HOME_HREF, {locale, site}) + const path = buildUrl(HOME_HREF) + history.push(path) // Close the drawer. @@ -123,7 +115,7 @@ const App = (props) => { } const onCartClick = () => { - const path = buildPathWithUrlConfig('/cart', configValues) + const path = buildUrl('/cart') history.push(path) // Close the drawer. @@ -133,7 +125,7 @@ const App = (props) => { const onAccountClick = () => { // Link to account page for registered customer, open auth modal otherwise if (customer.isRegistered) { - const path = buildPathWithUrlConfig('/account', configValues) + const path = buildUrl('/account') history.push(path) } else { // if they already are at the login page, do not show login modal @@ -143,7 +135,7 @@ const App = (props) => { } const onWishlistClick = () => { - const path = buildPathWithUrlConfig('/account/wishlist', configValues) + const path = buildUrl('/account/wishlist') history.push(path) } @@ -184,9 +176,7 @@ const App = (props) => { ))} @@ -194,9 +184,7 @@ const App = (props) => { {/* A wider fallback for user locales that the app does not support */} @@ -222,11 +210,15 @@ const App = (props) => { onClose={onClose} onLogoClick={onLogoClick} root={allCategories[DEFAULT_ROOT_CATEGORY]} + locale={locale} /> - + ) : ( diff --git a/packages/template-retail-react-app/app/components/_app/index.test.js b/packages/template-retail-react-app/app/components/_app/index.test.js index 5d09111d6a..3536e5f8ca 100644 --- a/packages/template-retail-react-app/app/components/_app/index.test.js +++ b/packages/template-retail-react-app/app/components/_app/index.test.js @@ -11,10 +11,10 @@ import {Helmet} from 'react-helmet' import App from './index.jsx' import {renderWithProviders} from '../../utils/test-utils' import {DEFAULT_LOCALE} from '../../utils/test-utils' -import useSite from '../../hooks/use-site' +import useMultiSite from '../../hooks/use-multi-site' import messages from '../../translations/compiled/en-GB.json' import mockConfig from '../../../config/mocks/default' -jest.mock('../../hooks/use-site', () => jest.fn()) +jest.mock('../../hooks/use-multi-site', () => jest.fn()) let windowSpy beforeAll(() => { jest.spyOn(console, 'log').mockImplementation(jest.fn()) @@ -40,8 +40,21 @@ describe('App', () => { ...mockConfig.app.sites[0], alias: 'uk' } + + const locale = DEFAULT_LOCALE + + const buildUrl = jest.fn().mockImplementation((href, site, locale) => { + return `${site ? `/${site}` : ''}${locale ? `/${locale}` : ''}${href}` + }) + + const resultUseMultiSite = { + site, + locale, + buildUrl + } + test('App component is rendered appropriately', () => { - useSite.mockImplementation(() => site) + useMultiSite.mockImplementation(() => resultUseMultiSite) renderWithProviders(

Any children here

@@ -66,7 +79,7 @@ describe('App', () => { }) test('The localized hreflang links exist in the html head', () => { - useSite.mockImplementation(() => site) + useMultiSite.mockImplementation(() => resultUseMultiSite) renderWithProviders( ) @@ -77,7 +90,7 @@ describe('App', () => { const hasGeneralLocale = ({hrefLang}) => hrefLang === DEFAULT_LOCALE.slice(0, 2) // `length + 2` because one for a general locale and the other with x-default value - expect(hreflangLinks.length).toBe(site.l10n.supportedLocales.length + 2) + expect(hreflangLinks.length).toBe(resultUseMultiSite.site.l10n.supportedLocales.length + 2) expect(hreflangLinks.some((link) => hasGeneralLocale(link))).toBe(true) expect(hreflangLinks.some((link) => link.hrefLang === 'x-default')).toBe(true) diff --git a/packages/template-retail-react-app/app/components/drawer-menu/index.jsx b/packages/template-retail-react-app/app/components/drawer-menu/index.jsx index cc60045281..4e272ba73c 100644 --- a/packages/template-retail-react-app/app/components/drawer-menu/index.jsx +++ b/packages/template-retail-react-app/app/components/drawer-menu/index.jsx @@ -52,7 +52,7 @@ import useCustomer from '../../commerce-api/hooks/useCustomer' import LoadingSpinner from '../loading-spinner' import useNavigation from '../../hooks/use-navigation' -import useSite from '../../hooks/use-site' +import useMultiSite from '../../hooks/use-multi-site' // The FONT_SIZES and FONT_WEIGHTS constants are used to control the styling for // the accordion buttons as their current depth. In the below definition we assign @@ -84,7 +84,7 @@ const DrawerMenu = ({isOpen, onClose = noop, onLogoClick = noop, root}) => { const styles = useMultiStyleConfig('DrawerMenu') const drawerSize = useBreakpointValue({sm: PHONE_DRAWER_SIZE, md: TABLET_DRAWER_SIZE}) const socialIconVariant = useBreakpointValue({base: 'flex', md: 'flex-start'}) - const site = useSite() + const {site, buildUrl} = useMultiSite() const {l10n} = site const [showLoading, setShowLoading] = useState(false) const onSignoutClick = async () => { @@ -272,7 +272,7 @@ const DrawerMenu = ({isOpen, onClose = noop, onLogoClick = noop, root}) => { locales={supportedLocaleIds} onSelect={(newLocale) => { // Update the `locale` in the URL. - const newUrl = getPathWithLocale(newLocale, { + const newUrl = getPathWithLocale(newLocale, buildUrl, { disallowParams: ['refine'] }) window.location = newUrl diff --git a/packages/template-retail-react-app/app/components/footer/index.jsx b/packages/template-retail-react-app/app/components/footer/index.jsx index c59efb62a9..a63c43202c 100644 --- a/packages/template-retail-react-app/app/components/footer/index.jsx +++ b/packages/template-retail-react-app/app/components/footer/index.jsx @@ -29,14 +29,15 @@ import SocialIcons from '../social-icons' import {HideOnDesktop, HideOnMobile} from '../responsive' import {getPathWithLocale} from '../../utils/url' import LocaleText from '../locale-text' -import useSite from '../../hooks/use-site' +import useMultiSite from '../../hooks/use-multi-site' const Footer = ({...otherProps}) => { const styles = useMultiStyleConfig('Footer') const intl = useIntl() const [locale, setLocale] = useState(intl.locale) - const site = useSite() + const {site, buildUrl} = useMultiSite() const {l10n} = site + const supportedLocaleIds = l10n?.supportedLocales.map((locale) => locale.id) const showLocaleSelector = supportedLocaleIds?.length > 1 @@ -136,7 +137,7 @@ const Footer = ({...otherProps}) => { setLocale(target.value) // Update the `locale` in the URL. - const newUrl = getPathWithLocale(target.value, { + const newUrl = getPathWithLocale(target.value, buildUrl, { disallowParams: ['refine'] }) diff --git a/packages/template-retail-react-app/app/components/link/index.jsx b/packages/template-retail-react-app/app/components/link/index.jsx index be8d2b4660..74b61b14c2 100644 --- a/packages/template-retail-react-app/app/components/link/index.jsx +++ b/packages/template-retail-react-app/app/components/link/index.jsx @@ -8,20 +8,12 @@ import React from 'react' import PropTypes from 'prop-types' import {Link as ChakraLink} from '@chakra-ui/react' import {Link as SPALink, NavLink as NavSPALink} from 'react-router-dom' -import {buildPathWithUrlConfig} from '../../utils/url' -import useSite from '../../hooks/use-site' -import useLocale from '../../hooks/use-locale' +import useMultiSite from '../../hooks/use-multi-site' const Link = React.forwardRef(({href, to, useNavLink = false, ...props}, ref) => { const _href = to || href - const site = useSite() - const locale = useLocale() - - // if alias is not defined, use site id - const updatedHref = buildPathWithUrlConfig(_href, { - locale: locale.alias || locale.id, - site: site.alias || site.id - }) + const {buildUrl} = useMultiSite() + const updatedHref = buildUrl(_href) return ( { delete window.location window.location = new URL('/us/en-US', 'https://www.example.com') const {getByText} = renderWithProviders(My Page, { - wrapperProps: {locale: 'en-US'} + wrapperProps: {locale: 'en-US', siteAlias: 'us', appConfig: mockConfig.app} }) expect(getByText(/My Page/i)).toHaveAttribute('href', '/us/en-US/mypage') }) @@ -49,7 +49,7 @@ test('renders a link with locale and site as query param', () => { delete window.location window.location = new URL('https://www.example.com/women/dresses?site=us&locale=en-US') const {getByText} = renderWithProviders(My Page, { - wrapperProps: {locale: 'en-US'} + wrapperProps: {locale: 'en-US', siteAlias: 'us', appConfig: newConfig.app} }) expect(getByText(/My Page/i)).toHaveAttribute('href', `/mypage?site=us&locale=en-US`) @@ -60,7 +60,7 @@ test('accepts `to` prop as well', () => { delete window.location window.location = new URL('us/en-US', 'https://www.example.com') const {getByText} = renderWithProviders(My Page, { - wrapperProps: {locale: 'en-US'} + wrapperProps: {locale: 'en-US', siteAlias: 'us', appConfig: mockConfig.app} }) expect(getByText(/My Page/i)).toHaveAttribute('href', '/us/en-US/mypage') }) diff --git a/packages/template-retail-react-app/app/components/search/index.test.js b/packages/template-retail-react-app/app/components/search/index.test.js index fed930fc70..bac41a35a4 100644 --- a/packages/template-retail-react-app/app/components/search/index.test.js +++ b/packages/template-retail-react-app/app/components/search/index.test.js @@ -12,6 +12,7 @@ import SearchInput from './index' import Suggestions from './partials/suggestions' import {noop} from '../../utils/utils' import mockSearchResults from '../../commerce-api/mocks/searchResults' +import mockConfig from '../../../config/mocks/default' jest.mock('../../commerce-api/utils', () => { const originalModule = jest.requireActual('../../commerce-api/utils') @@ -53,7 +54,9 @@ test('renders Popover if recent searches populated', async () => { }) test('changes url when enter is pressed', async () => { - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} + }) const searchInput = document.querySelector('input[type="search"]') await user.type(searchInput, 'Dresses{enter}') await waitFor(() => expect(window.location.pathname).toEqual(createPathWithDefaults('/search'))) diff --git a/packages/template-retail-react-app/app/contexts/index.js b/packages/template-retail-react-app/app/contexts/index.js index cf0d25cf82..af739bb574 100644 --- a/packages/template-retail-react-app/app/contexts/index.js +++ b/packages/template-retail-react-app/app/contexts/index.js @@ -46,6 +46,51 @@ CategoriesProvider.propTypes = { categories: PropTypes.object } +/** + * This is the global state for the multiples sites and locales supported in the App. + * + * To use the context simply import them into the component requiring context + * like the below example: + * + * import React, {useContext} from 'react' + * import {MultiSiteContext} from './contexts' + * + * export const RootCurrencyLabel = () => { + * const {site,locale,urlTemplate} = useContext(MultiSiteContext) + * return
{site} {locale}
+ * } + * + * Alternatively you can use the hook provided by us: + * + * import {useMultiSite} from './hooks' + * + * const {site, locale, buildUrl} = useMultiSite() + * @type {React.Context} + */ +export const MultiSiteContext = React.createContext() +export const MultiSiteProvider = ({ + site: initialSite = {}, + locale: initialLocale = {}, + buildUrl, + children +}) => { + const [site, setSite] = useState(initialSite) + const [locale, setLocale] = useState(initialLocale) + + return ( + + {children} + + ) +} + +MultiSiteProvider.propTypes = { + children: PropTypes.node.isRequired, + buildUrl: PropTypes.func, + site: PropTypes.object, + locale: PropTypes.string +} + /** * This is the global state for currency, we use this throughout the site. For example, on * the product-list, product-detail and cart and basket pages.. diff --git a/packages/template-retail-react-app/app/hooks/use-categories.js b/packages/template-retail-react-app/app/hooks/use-categories.js index 7da1452542..4b6dd59ca7 100644 --- a/packages/template-retail-react-app/app/hooks/use-categories.js +++ b/packages/template-retail-react-app/app/hooks/use-categories.js @@ -11,4 +11,10 @@ import {CategoriesContext} from '../contexts' * Custom React hook to get the categories * @returns {{categories: Object, setCategories: function}[]} */ -export const useCategories = () => useContext(CategoriesContext) +export const useCategories = () => { + const context = useContext(CategoriesContext) + if (context === undefined) { + throw new Error('useCategories must be used within CategoriesProvider') + } + return context +} diff --git a/packages/template-retail-react-app/app/hooks/use-currency.js b/packages/template-retail-react-app/app/hooks/use-currency.js index ec17d475e6..5527d8c6f2 100644 --- a/packages/template-retail-react-app/app/hooks/use-currency.js +++ b/packages/template-retail-react-app/app/hooks/use-currency.js @@ -11,4 +11,10 @@ import {CurrencyContext} from '../contexts' * Custom React hook to get the currency for the active locale and to change it to a different currency * @returns {{currency: string, setCurrency: function}[]} */ -export const useCurrency = () => useContext(CurrencyContext) +export const useCurrency = () => { + const context = useContext(CurrencyContext) + if (context === undefined) { + throw new Error('useCurrency must be used within CurrencyProvider') + } + return context +} diff --git a/packages/template-retail-react-app/app/hooks/use-locale.js b/packages/template-retail-react-app/app/hooks/use-locale.js deleted file mode 100644 index 179dd31f73..0000000000 --- a/packages/template-retail-react-app/app/hooks/use-locale.js +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import useSite from './use-site' -import {useMemo} from 'react' -import {resolveLocaleFromUrl} from '../utils/utils' -import {useLocation} from 'react-router-dom' - -/** - * This hook returns the locale object based on current location - * @return {object} locale - */ -const useLocale = () => { - const {pathname, search} = useLocation() - const site = useSite() - const locale = useMemo(() => { - return resolveLocaleFromUrl(`${pathname}${search}`) - }, [pathname, search, site]) - - return locale -} - -export default useLocale diff --git a/packages/template-retail-react-app/app/hooks/use-locale.test.js b/packages/template-retail-react-app/app/hooks/use-locale.test.js deleted file mode 100644 index cb768d11d5..0000000000 --- a/packages/template-retail-react-app/app/hooks/use-locale.test.js +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import {createMemoryHistory} from 'history' -import {screen} from '@testing-library/react' -import {Router} from 'react-router' -import React from 'react' - -import useLocale from './use-locale' -import {renderWithReactIntl} from '../utils/test-utils' - -const MockComponent = () => { - const locale = useLocale() - return
{JSON.stringify(locale)}
-} - -describe('useLocale', function() { - test('return the default locale', () => { - const history = createMemoryHistory() - history.push('/test/path') - renderWithReactIntl( - - - - ) - expect(screen.getByTestId('locale')).toHaveTextContent( - '{"id":"en-GB","preferredCurrency":"GBP"}' - ) - }) - - test('return the locale object that matches the site (from the url) and the locale from intl', () => { - const history = createMemoryHistory() - history.push('/us/en-CA/test/path') - renderWithReactIntl( - - - , - 'en-CA' - ) - expect(screen.getByTestId('locale')).toHaveTextContent( - '{"id":"en-CA","preferredCurrency":"USD"}' - ) - }) -}) diff --git a/packages/template-retail-react-app/app/hooks/use-multi-site.js b/packages/template-retail-react-app/app/hooks/use-multi-site.js new file mode 100644 index 0000000000..9e053ef1fa --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-multi-site.js @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import {useCallback, useContext} from 'react' +import {MultiSiteContext} from '../contexts' + +/** + * Custom React hook to get the function that returns usefule multi-site values, the site, the locale and + * the funtion used to build URLs following the App configuration. + * @returns {{site, locale, buildUrl: (function(*, *, *): *)}} + */ +const useMultiSite = () => { + const context = useContext(MultiSiteContext) + if (context === undefined) { + throw new Error('useMultiSite must be used within MultiSiteProvider') + } + const {buildUrl: originalFn, site, locale} = context + + const buildUrl = useCallback( + (path, siteRef, localeRef) => { + return originalFn( + path, + siteRef ? siteRef : site?.alias || site?.id, + localeRef ? localeRef : locale + ) + }, + [originalFn, site, locale] + ) + return {site, locale, buildUrl} +} + +export default useMultiSite diff --git a/packages/template-retail-react-app/app/hooks/use-multi-site.test.js b/packages/template-retail-react-app/app/hooks/use-multi-site.test.js new file mode 100644 index 0000000000..c7d4c6c3ff --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-multi-site.test.js @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2021, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React from 'react' +import {renderHook} from '@testing-library/react-hooks' +import useMultiSite from './use-multi-site' +import {MultiSiteProvider} from '../contexts' +import mockConfig from '../../config/mocks/default' +import {DEFAULT_LOCALE} from '../utils/test-utils' + +const wrapper = ({children}) => {children} + +let resultuseMultiSite = {} + +beforeEach(() => { + resultuseMultiSite = {} +}) + +const site = { + ...mockConfig.app.sites[0], + alias: 'uk' +} + +const locale = DEFAULT_LOCALE + +const buildUrl = jest.fn().mockImplementation((href, site, locale) => { + return `${site ? `/${site}` : ''}${locale ? `/${locale}` : ''}${href}` +}) + +const mockResultuseMultiSite = { + site, + locale, + buildUrl +} + +const mockUseContext = jest.fn().mockImplementation(() => mockResultuseMultiSite) + +React.useContext = mockUseContext +describe('useMultiSite', () => { + it('should set initial values', () => { + expect(resultuseMultiSite).toMatchObject({}) + + const {result} = renderHook(() => useMultiSite(), {wrapper}) + + expect(mockUseContext).toHaveBeenCalled() + expect(result.current).toHaveProperty('site') + expect(result.current).toHaveProperty('locale') + }) +}) diff --git a/packages/template-retail-react-app/app/hooks/use-navigation.js b/packages/template-retail-react-app/app/hooks/use-navigation.js index 9b02532fde..81eaab6f09 100644 --- a/packages/template-retail-react-app/app/hooks/use-navigation.js +++ b/packages/template-retail-react-app/app/hooks/use-navigation.js @@ -6,10 +6,7 @@ */ import {useCallback} from 'react' import {useHistory} from 'react-router' -import {useIntl} from 'react-intl' -import {buildPathWithUrlConfig} from '../utils/url' -import useSite from './use-site' -import {getLocaleByReference} from '../utils/utils' +import useMultiSite from './use-multi-site' /** * A convenience hook for programmatic navigation uses history's `push` or `replace`. The proper locale @@ -19,8 +16,8 @@ import {getLocaleByReference} from '../utils/utils' const useNavigation = () => { const history = useHistory() - const {locale: localeShortCode} = useIntl() - const site = useSite() + const {site, locale: localeShortCode, buildUrl} = useMultiSite() + return useCallback( /** * @@ -29,12 +26,7 @@ const useNavigation = () => { * @param {...any} args - additional args passed to `.push` or `.replace` */ (path, action = 'push', ...args) => { - const locale = getLocaleByReference(site, localeShortCode) - - const updatedHref = buildPathWithUrlConfig(path, { - locale: locale.alias || locale.id, - site: site.alias || site.id - }) + const updatedHref = buildUrl(path) history[action](path === '/' ? '/' : updatedHref, ...args) }, [localeShortCode, site] diff --git a/packages/template-retail-react-app/app/hooks/use-navigation.test.js b/packages/template-retail-react-app/app/hooks/use-navigation.test.js index 2a31873fd6..cb4ca6db65 100644 --- a/packages/template-retail-react-app/app/hooks/use-navigation.test.js +++ b/packages/template-retail-react-app/app/hooks/use-navigation.test.js @@ -56,7 +56,9 @@ const TestComponent = () => { test('prepends locale and site and calls history.push', () => { getConfig.mockImplementation(() => mockConfig) - const {getByTestId} = renderWithProviders() + const {getByTestId} = renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} + }) user.click(getByTestId('page1-link')) expect(mockHistoryPush).toHaveBeenCalledWith('/uk/en-GB/page1') }) @@ -74,7 +76,9 @@ test('append locale as path and site as query and calls history.push', () => { } } getConfig.mockImplementation(() => newConfig) - const {getByTestId} = renderWithProviders() + const {getByTestId} = renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', appConfig: newConfig.app} + }) user.click(getByTestId('page1-link')) expect(mockHistoryPush).toHaveBeenCalledWith('/en-GB/page1?site=uk') }) @@ -82,7 +86,9 @@ test('append locale as path and site as query and calls history.push', () => { test('works for any history method and args', () => { getConfig.mockImplementation(() => mockConfig) - const {getByTestId} = renderWithProviders() + const {getByTestId} = renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} + }) user.click(getByTestId('page2-link')) expect(mockHistoryReplace).toHaveBeenCalledWith('/uk/en-GB/page2', {}) @@ -91,7 +97,9 @@ test('works for any history method and args', () => { test('if given the path to root or homepage, will not prepend the locale', () => { getConfig.mockImplementation(() => mockConfig) - const {getByTestId} = renderWithProviders() + const {getByTestId} = renderWithProviders(, { + wrapperProps: {siteAlias: 'us', locale: 'en-US'} + }) user.click(getByTestId('page4-link')) expect(mockHistoryPush).toHaveBeenCalledWith('/') }) diff --git a/packages/template-retail-react-app/app/hooks/use-site.js b/packages/template-retail-react-app/app/hooks/use-site.js deleted file mode 100644 index af3b9168f5..0000000000 --- a/packages/template-retail-react-app/app/hooks/use-site.js +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import {useLocation} from 'react-router-dom' -import {resolveSiteFromUrl} from '../utils/site-utils' -import {useMemo} from 'react' - -/** - * This hook returns the current site based on current location - * - * @returns {Object} - current site - */ -const useSite = () => { - const {pathname, search} = useLocation() - const site = useMemo(() => { - return resolveSiteFromUrl(`${pathname}${search}`) - }, [pathname, search]) - return site -} - -export default useSite diff --git a/packages/template-retail-react-app/app/hooks/use-site.test.js b/packages/template-retail-react-app/app/hooks/use-site.test.js deleted file mode 100644 index 5f52ef354b..0000000000 --- a/packages/template-retail-react-app/app/hooks/use-site.test.js +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import {createMemoryHistory} from 'history' -import {render, screen} from '@testing-library/react' -import {Router} from 'react-router' -import React from 'react' -import useSite from './use-site' - -afterEach(() => { - jest.clearAllMocks() -}) - -const MockComponent = () => { - const site = useSite() - return
{JSON.stringify(site)}
-} - -describe('useSite', function() { - test('returns the default site when there is no ref in the url ', () => { - const history = createMemoryHistory() - history.push('/test/path') - render( - - - - ) - expect(screen.getByTestId('site')).toHaveTextContent( - '{"id":"site-1","l10n":{"defaultLocale":"en-GB","supportedLocales":[{"id":"en-GB","preferredCurrency":"GBP"},{"id":"fr-FR","alias":"fr","preferredCurrency":"EUR"},{"id":"it-IT","preferredCurrency":"EUR"}]},"alias":"uk"}' - ) - }) - - test('returns site-2 as the result ', () => { - const history = createMemoryHistory() - history.push('/us/test/path') - render( - - - - ) - expect(screen.getByTestId('site')).toHaveTextContent( - '{"id":"site-2","l10n":{"defaultLocale":"en-US","supportedLocales":[{"id":"en-US","preferredCurrency":"USD"},{"id":"en-CA","preferredCurrency":"USD"}]},"alias":"us"}' - ) - }) -}) diff --git a/packages/template-retail-react-app/app/pages/account/index.jsx b/packages/template-retail-react-app/app/pages/account/index.jsx index 77976ad57d..a2ef39a2cc 100644 --- a/packages/template-retail-react-app/app/pages/account/index.jsx +++ b/packages/template-retail-react-app/app/pages/account/index.jsx @@ -37,22 +37,20 @@ import {useLocation} from 'react-router-dom' import {messages, navLinks} from './constant' import useNavigation from '../../hooks/use-navigation' import LoadingSpinner from '../../components/loading-spinner' -import {buildPathWithUrlConfig} from '../../utils/url' -import useLocale from '../../hooks/use-locale' -import useSite from '../../hooks/use-site' +import useMultiSite from '../../hooks/use-multi-site' const Account = () => { const {path} = useRouteMatch() const {formatMessage} = useIntl() const customer = useCustomer() - const locale = useLocale() const location = useLocation() - const site = useSite() const navigate = useNavigation() const [mobileNavIndex, setMobileNavIndex] = useState(-1) const [showLoading, setShowLoading] = useState(false) + const {buildUrl} = useMultiSite() + const onSignoutClick = async () => { setShowLoading(true) await customer.logout() @@ -91,10 +89,7 @@ const Account = () => { // Using Redirect allows us to store the directed page to location // so we can direct users back after they are successfully log in if (customer.authType != null && !customer.isRegistered) { - const path = buildPathWithUrlConfig('/login', { - locale: locale.alias || locale.id, - site: site.alias || site.id - }) + const path = buildUrl('/login') return } diff --git a/packages/template-retail-react-app/app/pages/account/index.test.js b/packages/template-retail-react-app/app/pages/account/index.test.js index 51f1f8b8ae..eefb1212ca 100644 --- a/packages/template-retail-react-app/app/pages/account/index.test.js +++ b/packages/template-retail-react-app/app/pages/account/index.test.js @@ -18,6 +18,7 @@ import { } from '../../commerce-api/mock-data' import useCustomer from '../../commerce-api/hooks/useCustomer' import Account from './index' +import mockConfig from '../../../config/mocks/default' jest.mock('../../commerce-api/utils', () => { const originalModule = jest.requireActual('../../commerce-api/utils') @@ -75,12 +76,16 @@ test('Redirects to login page if the customer is not logged in', async () => { return res(ctx.delay(0), ctx.status(200), ctx.json(mockedGuestCustomer)) }) ) - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} + }) await waitFor(() => expect(window.location.pathname).toEqual(`${expectedBasePath}/login`)) }) test('Provides navigation for subpages', async () => { - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} + }) expect(await screen.findByTestId('account-page')).toBeInTheDocument() const nav = within(screen.getByTestId('account-detail-nav')) @@ -108,7 +113,9 @@ test('Renders account detail page by default for logged-in customer', async () = }) test('Allows customer to sign out', async () => { - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} + }) expect(await screen.findByTestId('account-detail-page')).toBeInTheDocument() user.click(screen.getAllByText(/Log Out/)[0]) await waitFor(() => { diff --git a/packages/template-retail-react-app/app/pages/account/orders.test.js b/packages/template-retail-react-app/app/pages/account/orders.test.js index 1fc3c942cb..8e33706165 100644 --- a/packages/template-retail-react-app/app/pages/account/orders.test.js +++ b/packages/template-retail-react-app/app/pages/account/orders.test.js @@ -13,6 +13,7 @@ import {renderWithProviders, createPathWithDefaults, setupMockServer} from '../. import {mockOrderHistory, mockOrderProducts} from '../../commerce-api/mock-data' import useCustomer from '../../commerce-api/hooks/useCustomer' import Orders from './orders' +import mockConfig from '../../../config/mocks/default' jest.mock('../../commerce-api/utils', () => { const originalModule = jest.requireActual('../../commerce-api/utils') @@ -66,7 +67,9 @@ afterEach(() => { afterAll(() => server.close()) test('Renders order history and details', async () => { - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} + }) expect(await screen.findByTestId('account-order-history-page')).toBeInTheDocument() expect(await screen.findAllByText(/Ordered: /i)).toHaveLength(3) expect( diff --git a/packages/template-retail-react-app/app/pages/checkout/confirmation.test.js b/packages/template-retail-react-app/app/pages/checkout/confirmation.test.js index 5b88995c08..411e3e92c0 100644 --- a/packages/template-retail-react-app/app/pages/checkout/confirmation.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/confirmation.test.js @@ -211,7 +211,9 @@ afterEach(() => { afterAll(() => server.close()) test('Navigates to homepage when no order present', async () => { - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', locale: 'en-GB'} + }) expect(screen.queryByTestId('sf-checkout-confirmation-container')).not.toBeInTheDocument() await waitFor(() => { expect(window.location.pathname).toEqual('/') diff --git a/packages/template-retail-react-app/app/pages/checkout/index.test.js b/packages/template-retail-react-app/app/pages/checkout/index.test.js index ee8039f728..9c128cba5d 100644 --- a/packages/template-retail-react-app/app/pages/checkout/index.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/index.test.js @@ -23,6 +23,7 @@ import { mockedCustomerProductLists, productsResponse } from '../../commerce-api/mock-data' +import mockConfig from '../../../config/mocks/default' jest.setTimeout(60000) @@ -238,7 +239,9 @@ test('Can proceed through checkout steps as guest', async () => { // Set the initial browser router path and render our component tree. window.history.pushState({}, 'Checkout', createPathWithDefaults('/checkout')) - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} + }) // Wait for checkout to load and display first step await screen.findByText(/checkout as guest/i) @@ -446,7 +449,9 @@ test('Can proceed through checkout as registered customer', async () => { // Set the initial browser router path and render our component tree. window.history.pushState({}, 'Checkout', createPathWithDefaults('/checkout')) - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', locale: 'en-GB', appConfig: mockConfig.app} + }) // Switch to login const haveAccountButton = await screen.findByText(/already have an account/i) @@ -580,7 +585,9 @@ test('Can edit address during checkout as a registered customer', async () => { // Set the initial browser router path and render our component tree. window.history.pushState({}, 'Checkout', createPathWithDefaults('/checkout')) - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', locale: 'en-GB'} + }) // Switch to login const haveAccountButton = await screen.findByText(/already have an account/i) @@ -685,7 +692,9 @@ test('Can add address during checkout as a registered customer', async () => { // Set the initial browser router path and render our component tree. window.history.pushState({}, 'Checkout', createPathWithDefaults('/checkout')) - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'us', locale: 'en-US'} + }) // Switch to login const haveAccountButton = await screen.findByText(/already have an account/i) diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js index ccdab311d9..9bae5373fa 100644 --- a/packages/template-retail-react-app/app/pages/login/index.test.js +++ b/packages/template-retail-react-app/app/pages/login/index.test.js @@ -14,6 +14,7 @@ import {BrowserRouter as Router, Route} from 'react-router-dom' import Account from '../account' import Registration from '../registration' import ResetPassword from '../reset-password' +import mockConfig from '../../../config/mocks/default' jest.setTimeout(60000) @@ -139,7 +140,9 @@ test('Allows customer to sign in to their account', async () => { }) ) // render our test component - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', locale: 'en-GB', appConfig: mockConfig.app} + }) // enter credentials and submit user.type(screen.getByLabelText('Email'), 'darek@test.com') @@ -160,7 +163,9 @@ test('Renders error when given incorrect log in credentials', async () => { ) // render our test component - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', locale: 'en-GB', appConfig: mockConfig.app} + }) // enter credentials and submit user.type(screen.getByLabelText('Email'), 'foo@test.com') @@ -179,7 +184,9 @@ test('Renders error when given incorrect log in credentials', async () => { test('should navigate to sign in page when the user clicks Create Account', async () => { // render our test component - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', locale: 'en-GB', appConfig: mockConfig.app} + }) user.click(screen.getByText(/Create Account/i)) // wait for sign up page to appear @@ -188,7 +195,9 @@ test('should navigate to sign in page when the user clicks Create Account', asyn test('should navigate to reset password page when the user clicks Forgot Password', async () => { // render our test component - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', locale: 'en-GB', appConfig: mockConfig.app} + }) user.click(screen.getByText(/forgot password/i)) // wait for sign up page to appear diff --git a/packages/template-retail-react-app/app/pages/product-list/index.test.js b/packages/template-retail-react-app/app/pages/product-list/index.test.js index 4ecf686551..ebdd925be9 100644 --- a/packages/template-retail-react-app/app/pages/product-list/index.test.js +++ b/packages/template-retail-react-app/app/pages/product-list/index.test.js @@ -166,7 +166,9 @@ test('show login modal when an unauthenticated user tries to add an item to wish }) test('clicking a filter will change url', async () => { - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', locale: 'en-GB'} + }) user.click(screen.getByText(/Beige/i)) await waitFor(() => expect(window.location.search).toEqual( @@ -181,7 +183,9 @@ test('click on filter All should clear out all the filter in search params', asy 'ProductList', 'uk/en-GB/category/mens-clothing-jackets?limit=25&refine=c_refinementColor%3DBeige&sort=best-matches' ) - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', locale: 'en-GB'} + }) const clearAllButton = await screen.findAllByText(/Clear All/i) user.click(clearAllButton[0]) await waitFor(() => expect(window.location.search).toEqual('')) diff --git a/packages/template-retail-react-app/app/pages/registration/index.test.jsx b/packages/template-retail-react-app/app/pages/registration/index.test.jsx index e24db4d010..f55f938629 100644 --- a/packages/template-retail-react-app/app/pages/registration/index.test.jsx +++ b/packages/template-retail-react-app/app/pages/registration/index.test.jsx @@ -11,6 +11,7 @@ import {renderWithProviders} from '../../utils/test-utils' import Registration from '.' import {BrowserRouter as Router, Route} from 'react-router-dom' import Account from '../account' +import mockConfig from '../../../config/mocks/default' jest.setTimeout(60000) @@ -147,7 +148,9 @@ test('Allows customer to create an account', async () => { }) // render our test component - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} + }) // fill out form and submit const withinForm = within(screen.getByTestId('sf-auth-modal-form')) diff --git a/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx b/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx index 08962ffe6b..297f45eff3 100644 --- a/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx +++ b/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx @@ -10,6 +10,7 @@ import user from '@testing-library/user-event' import {rest} from 'msw' import {createPathWithDefaults, renderWithProviders, setupMockServer} from '../../utils/test-utils' import ResetPassword from '.' +import mockConfig from '../../../config/mocks/default' jest.setTimeout(60000) @@ -87,7 +88,9 @@ afterAll(() => server.close()) test('Allows customer to go to sign in page', async () => { // render our test component - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} + }) user.click(screen.getByText('Sign in')) await waitFor(() => { @@ -112,7 +115,9 @@ test('Allows customer to generate password token', async () => { ) // render our test component - renderWithProviders() + renderWithProviders(, { + wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} + }) // enter credentials and submit user.type(screen.getByLabelText('Email'), 'foo@test.com') diff --git a/packages/template-retail-react-app/app/utils/site-utils.test.js b/packages/template-retail-react-app/app/utils/site-utils.test.js index 67b5711fac..eee6633487 100644 --- a/packages/template-retail-react-app/app/utils/site-utils.test.js +++ b/packages/template-retail-react-app/app/utils/site-utils.test.js @@ -124,6 +124,7 @@ describe('getDefaultSite', function() { id: 'site-2', l10n: { defaultLocale: 'en-US', + defaultCurrency: 'USD', supportedLocales: [ { id: 'en-US', @@ -152,6 +153,7 @@ describe('getSites', function() { alias: 'uk', l10n: { defaultLocale: 'en-GB', + defaultCurrency: 'GBP', supportedLocales: [ { id: 'en-GB', @@ -174,6 +176,7 @@ describe('getSites', function() { alias: 'us', l10n: { defaultLocale: 'en-US', + defaultCurrency: 'USD', supportedLocales: [ { id: 'en-US', diff --git a/packages/template-retail-react-app/app/utils/test-utils.js b/packages/template-retail-react-app/app/utils/test-utils.js index 7a2086d62b..fc4f684957 100644 --- a/packages/template-retail-react-app/app/utils/test-utils.js +++ b/packages/template-retail-react-app/app/utils/test-utils.js @@ -21,7 +21,7 @@ import { CustomerProductListsProvider } from '../commerce-api/contexts' import {AddToCartModalContext} from '../hooks/use-add-to-cart-modal' -import {app as appConfig} from '../../config/default' +import {app as appDefaultConfig} from '../../config/default' import {IntlProvider} from 'react-intl' import { mockCategories as initialMockCategories, @@ -43,9 +43,12 @@ export const SUPPORTED_LOCALES = [ preferredCurrency: 'EUR' } ] +export const DEFAULT_SITE = 'global' // Contexts -import {CategoriesProvider, CurrencyProvider} from '../contexts' -import {buildPathWithUrlConfig} from './url' +import {CategoriesProvider, CurrencyProvider, MultiSiteProvider} from '../contexts' + +import {createUrlTemplate} from './url' +import {getDefaultSite, getSites} from './site-utils' export const renderWithReactIntl = (node, locale = DEFAULT_LOCALE) => { return render( @@ -59,8 +62,8 @@ export const renderWithRouter = (node) => renderWithReactIntl({node} { const api = new CommerceAPI({ - ...appConfig.commerceAPI, - einsteinConfig: appConfig.einsteinAPI, + ...appDefaultConfig.commerceAPI, + einsteinConfig: appDefaultConfig.einsteinAPI, proxy: undefined }) return renderWithReactIntl( @@ -81,7 +84,9 @@ export const TestProviders = ({ initialCustomer = null, initialCategories = initialMockCategories, locale = DEFAULT_LOCALE, - messages = fallbackMessages + messages = fallbackMessages, + appConfig = appDefaultConfig, + siteAlias = DEFAULT_SITE }) => { const mounted = useRef() // We use this to track mounted state. @@ -121,27 +126,39 @@ export const TestProviders = ({ onClose: () => {} } + const sites = getSites() + const site = + sites.find((site) => { + return site.alias === siteAlias || site.id === appConfig['site'] + }) || getDefaultSite() + + const buildUrl = createUrlTemplate(appConfig, site.alias || site.id, locale) + return ( - - - - - - - - - - {children} - - - - - - - - - + + + + + + + + + + + {children} + + + + + + + + + + ) } @@ -153,7 +170,9 @@ TestProviders.propTypes = { initialCategories: PropTypes.element, initialProductLists: PropTypes.object, messages: PropTypes.object, - locale: PropTypes.string + locale: PropTypes.string, + appConfig: PropTypes.object, + siteAlias: PropTypes.string } /** @@ -185,10 +204,9 @@ export const createPathWithDefaults = (path) => { const siteAlias = app.siteAliases[defaultSite.id] const defaultLocale = defaultSite.l10n.defaultLocale - const updatedPath = buildPathWithUrlConfig(path, { - site: siteAlias || defaultSite.id, - locale: defaultLocale - }) + const buildUrl = createUrlTemplate(app, siteAlias || defaultSite, defaultLocale) + + const updatedPath = buildUrl(path, siteAlias || defaultSite.id, defaultLocale) return updatedPath } diff --git a/packages/template-retail-react-app/app/utils/url.js b/packages/template-retail-react-app/app/utils/url.js index 468f5851c9..4e8c9371d1 100644 --- a/packages/template-retail-react-app/app/utils/url.js +++ b/packages/template-retail-react-app/app/utils/url.js @@ -6,7 +6,7 @@ */ import {getAppOrigin} from 'pwa-kit-react-sdk/utils/url' -import {getLocaleByReference, getParamsFromPath, getUrlConfig} from './utils' +import {getLocaleByReference, getParamsFromPath} from './utils' import {getDefaultSite, getSites} from './site-utils' import {HOME_HREF, urlPartPositions} from '../constants' @@ -110,7 +110,7 @@ export const categoryUrlBuilder = (category) => encodeURI(`/category/${category. export const productUrlBuilder = (product) => encodeURI(`/product/${product.id}`) /** - * Given a search term, contructs a search url. + * Given a search term, constructs a search url. * * @param {string} searchTerm * @returns {string} @@ -122,11 +122,12 @@ export const searchUrlBuilder = (searchTerm) => `/search?q=${searchTerm}` * Based on your app configuration, this function will replace your current locale shortCode with a new one * * @param {String} shortCode - The locale short code. - * @param {Object} [opts] - Options, if there's any. + * @param {function(*, *, *, *=): string} - Generates a site URL from the provided path, site and locale. + * @param {string[]} opts.disallowParams - URL parameters to remove * @param {Object} opts.location - location object to replace the default `window.location` * @returns {String} url - The relative URL for the specific locale. */ -export const getPathWithLocale = (shortCode, opts = {}) => { +export const getPathWithLocale = (shortCode, buildUrl, opts = {}) => { const location = opts.location ? opts.location : window.location let {siteRef, localeRef} = getParamsFromPath(`${location.pathname}${location.search}`) let {pathname, search} = location @@ -145,50 +146,92 @@ export const getPathWithLocale = (shortCode, opts = {}) => { search = search.replace(/&$/, '') const defaultSite = getDefaultSite() - const isHomeRef = pathname === HOME_HREF - const isDefaultLocaleOfDefaultSite = shortCode === defaultSite.l10n.defaultLocale - const isDefaultSite = siteRef === defaultSite.alias || siteRef === defaultSite.id + // Remove query parameters + const {disallowParams = []} = opts + + let queryString = new URLSearchParams(`${search}`) + + if (disallowParams.length) { + disallowParams.forEach((param) => { + queryString.delete(param) + }) + } + // rebuild the url with new locale, - const newUrl = buildPathWithUrlConfig( - `${pathname}${search}`, - { - // By default, as for home page, when the values of site and locale belongs to the default site, - // they will be not shown in the url just - site: - isDefaultLocaleOfDefaultSite && isDefaultSite && isHomeRef - ? '' - : siteRef || defaultSite.alias || defaultSite.id, - locale: isDefaultLocaleOfDefaultSite && isDefaultSite && isHomeRef ? '' : shortCode - }, - opts + const newUrl = buildUrl( + `${pathname}${Array.from(queryString).length !== 0 ? `?${queryString}` : ''}`, + // By default, as for home page, when the values of site and locale belongs to the default site, + // they will be not shown in the url just + defaultSite.alias || defaultSite.id, + shortCode ) return newUrl } /** - * Builds the Home page URL for a given locale and site. - * By default, when the values of site and locale belongs to the default site, - * they will be not shown in the url. + * Generates the URL Template literal (Template string) used to build URLs in the App according + * the current selected site/locale and the default App URL configuration. * - * Adjust the logic here to fit your cases - * - * @param homeHref - * @param options - * @returns {string} + * @param appConfig Application default configuration. + * @param siteRef Current selected Site reference. The value can be the Site id or alias. + * @param localeRef Current selected Locale reference. The value can be the Locale id or alias. + * @returns {function(*, *, *): string} function providing: path, site and locale generates a URL. */ -export const homeUrlBuilder = (homeHref, options = {}) => { - const {locale, site} = options +export const createUrlTemplate = (appConfig, siteRef, localeRef) => { + const {site: siteConfig, locale: localeConfig, showDefaults: showDefaultsConfig} = appConfig.url const defaultSite = getDefaultSite() - const isDefaultLocaleOfDefaultSite = - locale.alias === defaultSite.l10n.defaultLocale || - locale.id === defaultSite.l10n.defaultLocale - const isDefaultSite = site.id === defaultSite.id || site.alias === defaultSite.alias - const updatedUrl = buildPathWithUrlConfig(homeHref, { - locale: isDefaultLocaleOfDefaultSite && isDefaultSite ? '' : locale.alias || locale.id, - site: isDefaultLocaleOfDefaultSite && isDefaultSite ? '' : site.alias || site.id - }) - return encodeURI(updatedUrl) + const sites = getSites() + const siteAliasOrIdRef = + sites.find((site) => { + return site.alias === siteRef || site.id === siteRef + }) || defaultSite + const defaultLocale = getLocaleByReference( + siteAliasOrIdRef, + siteAliasOrIdRef.l10n.defaultLocale + ) + + const isDefaultSite = + defaultSite.id === siteRef || (defaultSite.alias && defaultSite.alias === siteRef) + const isDefaultLocale = + defaultLocale.id === localeRef || (defaultLocale.alias && defaultLocale.alias === localeRef) + + const querySite = + (siteConfig === urlPartPositions.QUERY_PARAM && showDefaultsConfig) || + (siteConfig === urlPartPositions.QUERY_PARAM && !showDefaultsConfig && !isDefaultSite) + const queryLocale = + (localeConfig === urlPartPositions.QUERY_PARAM && showDefaultsConfig) || + (localeConfig === urlPartPositions.QUERY_PARAM && !showDefaultsConfig && !isDefaultLocale) + + const isQuery = querySite || queryLocale + + const pathSite = + (siteConfig === urlPartPositions.PATH && showDefaultsConfig) || + (siteConfig === urlPartPositions.PATH && !showDefaultsConfig && !isDefaultSite) + const pathLocale = + (localeConfig === urlPartPositions.PATH && showDefaultsConfig) || + (localeConfig === urlPartPositions.PATH && !showDefaultsConfig && !isDefaultLocale) + + return (path, site, locale) => { + const isHomeWithDefaultSiteAndLocale = + path === HOME_HREF && + (defaultSite.id === site || (defaultSite.alias && defaultSite.alias === site)) && + (defaultLocale.id === locale || (defaultLocale.alias && defaultLocale.alias === locale)) + + const sitePath = pathSite && site && !isHomeWithDefaultSiteAndLocale ? `/${site}` : '' + const localePath = + pathLocale && locale && !isHomeWithDefaultSiteAndLocale ? `/${locale}` : '' + + const hasQuery = isQuery && (site || locale) && !isHomeWithDefaultSiteAndLocale + let queryString = '' + if (hasQuery) { + const searchParams = new URLSearchParams() + querySite && site && searchParams.append('site', site) + queryLocale && locale && searchParams.append('locale', locale) + queryString = `?${searchParams.toString()}` + } + return `${sitePath}${localePath}${path}${queryString}` + } } /* @@ -224,98 +267,3 @@ export const removeQueryParamsFromPath = (path, keys) => { return `${pathname}${paramStr && '?'}${paramStr}` } - -/** - * Rebuild the path with locale/site values with a given url - * The position of those values will based on the url config of your current app configuration. - * - * @param {string} relativeUrl - the base relative Url to be reconstructed on - * @param {object} configValues - object that contains values of url config - * @param {Object} [opts] - Options, if there's any. - * @param {string[]} opts.disallowParams - URL parameters to remove - * @return {string} - an output path that has locale and site - * - * @example - * // configuration - * url { - * locale: "query_param", - * site: "path", - * showDefaults: true - * } - * - * - * const site = { - * id: 'RefArch', - * alias: 'global' - * l10n: { - * defaultLocale: 'en-GB' - * supportedLocales: [ - * {id: 'en-GB', preferCurrency: 'GBP'} - * ] - * // other props - * } - * } - * buildPathWithUrlConfig('/women/dresses', {locale: 'en-GB', site: 'global'}) - * => /global/women/dresses?locale=en-GB - * - */ -export const buildPathWithUrlConfig = (relativeUrl, configValues = {}, opts = {}) => { - const urlConfig = getUrlConfig() - const sites = getSites() - const defaultSite = getDefaultSite() - const site = - sites.find((site) => { - return site.alias === configValues['site'] || site.id === configValues['site'] - }) || defaultSite - const defaultLocale = getLocaleByReference(site, site.l10n.defaultLocale) - const defaultLocaleRefs = [defaultLocale.alias, defaultLocale.id].filter(Boolean) - const {disallowParams = []} = opts - if (!Object.values(configValues).length) return relativeUrl - const [pathname, search] = relativeUrl.split('?') - - const params = new URLSearchParams(search) - // Remove any disallowed params. - if (disallowParams.length) { - disallowParams.forEach((param) => { - params.delete(param) - }) - } - - const queryParams = {...Object.fromEntries(params)} - let basePathSegments = [] - - // get the default values for site and locale - const showDefaults = urlConfig.showDefaults - - const defaultSiteRefs = [defaultSite.id, defaultSite.alias] - const defaultValues = [...defaultSiteRefs, ...defaultLocaleRefs] - - const options = ['site', 'locale'] - options.forEach((option) => { - const position = urlConfig[option] || urlPartPositions.NONE - const val = configValues[option] - if (position === urlPartPositions.PATH) { - // if showDefaults is false, the default value will not be show in the url - if (!showDefaults && defaultValues.includes(val)) { - return - } - basePathSegments.push(val) - } else if (position === urlPartPositions.QUERY_PARAM) { - // if showDefaults is false, the default value will not be show in the url - if (!showDefaults && defaultValues.includes(val)) { - return - } - queryParams[option] = val - } - }) - // filter out falsy (empty string, undefined, null, etc) values in the array - basePathSegments = basePathSegments.filter(Boolean) - let updatedPath = `${ - basePathSegments.length ? `/${basePathSegments.join('/')}` : '' - }${pathname}` - // append the query param to pathname - if (Object.keys(queryParams).length) { - updatedPath = rebuildPathWithParams(updatedPath, queryParams) - } - return updatedPath -} diff --git a/packages/template-retail-react-app/app/utils/url.test.js b/packages/template-retail-react-app/app/utils/url.test.js index 6b476bdbd2..c938511e8d 100644 --- a/packages/template-retail-react-app/app/utils/url.test.js +++ b/packages/template-retail-react-app/app/utils/url.test.js @@ -11,11 +11,10 @@ import { productUrlBuilder, searchUrlBuilder, getPathWithLocale, - homeUrlBuilder, rebuildPathWithParams, removeQueryParamsFromPath, - buildPathWithUrlConfig, - absoluteUrl + absoluteUrl, + createUrlTemplate } from './url' import {getUrlConfig} from './utils' import mockConfig from '../../config/mocks/default' @@ -126,17 +125,20 @@ describe('url builder test', () => { describe('getPathWithLocale', () => { getUrlConfig.mockImplementation(() => mockConfig.app.url) + test('getPathWithLocale returns expected for PLP', () => { const location = new URL('http://localhost:3000/uk/it-IT/category/newarrivals-womens') + const buildUrl = createUrlTemplate(mockConfig.app, 'uk', 'it-IT') - const relativeUrl = getPathWithLocale('fr-FR', {location}) + const relativeUrl = getPathWithLocale('fr-FR', buildUrl, {location}) expect(relativeUrl).toEqual(`/uk/fr-FR/category/newarrivals-womens`) }) test('getPathWithLocale uses default site for siteRef when it is no defined in the url', () => { const location = new URL('http://localhost:3000/category/newarrivals-womens') + const buildUrl = createUrlTemplate(mockConfig.app, 'uk', 'it-IT') - const relativeUrl = getPathWithLocale('fr-FR', {location}) + const relativeUrl = getPathWithLocale('fr-FR', buildUrl, {location}) expect(relativeUrl).toEqual(`/uk/fr-FR/category/newarrivals-womens`) }) @@ -144,8 +146,9 @@ describe('getPathWithLocale', () => { const location = new URL( 'http://localhost:3000/uk/it-IT/category/newarrivals-womens?limit=25&refine=c_refinementColor%3DBianco&sort=best-matches&offset=25' ) + const buildUrl = createUrlTemplate(mockConfig.app, 'uk', 'it-IT') - const relativeUrl = getPathWithLocale('fr-FR', { + const relativeUrl = getPathWithLocale('fr-FR', buildUrl, { disallowParams: ['refine'], location }) @@ -156,20 +159,22 @@ describe('getPathWithLocale', () => { test('getPathWithLocale returns expected for Homepage', () => { const location = new URL('http://localhost:3000/uk/it-IT/') + const buildUrl = createUrlTemplate(mockConfig.app, 'uk', 'it-IT') - const relativeUrl = getPathWithLocale('fr-FR', {location}) + const relativeUrl = getPathWithLocale('fr-FR', buildUrl, {location}) expect(relativeUrl).toEqual(`/uk/fr-FR/`) }) test('getPathWithLocale returns / when both site and locale are default', () => { - const location = new URL('http://localhost:3000/uk/it-IT/') + const location = new URL('http://localhost:3000/') + const buildUrl = createUrlTemplate(mockConfig.app, 'uk', 'en-GB') - const relativeUrl = getPathWithLocale('en-GB', {location}) + const relativeUrl = getPathWithLocale('en-GB', buildUrl, {location}) expect(relativeUrl).toEqual(`/`) }) }) -describe('homeUrlBuilder', () => { +describe('createUrlTemplate tests', () => { const defaultSite = mockConfig.app.sites[0] const defaultAlias = mockConfig.app.siteAliases[defaultSite.id] const defaultSiteMock = {...defaultSite, alias: defaultAlias} @@ -177,139 +182,162 @@ describe('homeUrlBuilder', () => { const nonDefaultSite = mockConfig.app.sites[1] const nonDefaultAlias = mockConfig.app.siteAliases[nonDefaultSite.id] const nonDefaultSiteMock = {...nonDefaultSite, alias: nonDefaultAlias} - const cases = [ - { - urlConfig: { - locale: 'path', - site: 'path', - showDefaults: true - }, - site: defaultSiteMock, - locale: {id: 'en-GB'}, - expectedRes: '/' - }, - { - urlConfig: { - locale: 'query_param', - site: 'query_param', - showDefaults: true - }, - site: defaultSiteMock, - locale: {id: 'en-GB'}, - expectedRes: '/' - }, - { - urlConfig: { - locale: 'path', - site: 'path', - showDefaults: false - }, - site: defaultSiteMock, - locale: {id: 'en-GB'}, - expectedRes: '/' - }, - { - urlConfig: { - locale: 'query_param', - site: 'query_param', - showDefaults: false - }, - site: defaultSiteMock, - locale: {id: 'en-GB'}, - expectedRes: '/' - }, - { - urlConfig: { - locale: 'path', - site: 'path', - showDefaults: true - }, - site: defaultSiteMock, - locale: {id: 'fr-FR'}, - expectedRes: '/uk/fr-FR/' - }, - { - urlConfig: { - locale: 'path', - site: 'path', - showDefaults: false - }, - site: defaultSiteMock, - locale: {id: 'fr-FR'}, - expectedRes: '/fr-FR/' - }, - { - urlConfig: { - locale: 'query_param', - site: 'query_param', - showDefaults: true - }, - site: defaultSiteMock, - locale: {id: 'fr-FR'}, - expectedRes: '/?site=uk&locale=fr-FR' - }, - { - urlConfig: { - locale: 'path', - site: 'path', - showDefaults: true - }, - site: nonDefaultSiteMock, - locale: {id: 'en-US'}, - expectedRes: '/us/en-US/' - }, - { - urlConfig: { - locale: 'query_param', - site: 'path', - showDefaults: true - }, - site: nonDefaultSiteMock, - locale: {id: 'en-US'}, - expectedRes: '/us/?locale=en-US' - }, - { - urlConfig: { - locale: 'path', - site: 'path', - showDefaults: false - }, - site: nonDefaultSiteMock, - locale: {id: 'en-US'}, // default locale of the nonDefault Site - expectedRes: '/us/' - }, - { - urlConfig: { - locale: 'query_param', - site: 'path', - showDefaults: false - }, - site: nonDefaultSiteMock, - locale: {id: 'en-US'}, // default locale of the nonDefault Site - expectedRes: '/us/' - }, - { - urlConfig: { - locale: 'query_param', - site: 'query_param', - showDefaults: true - }, - site: nonDefaultSiteMock, - locale: {id: 'en-US'}, // default locale of the nonDefault Site - expectedRes: '/?site=us&locale=en-US' + + const configValues = ['path', 'query_param', 'none'] + + let cases = [] + for (let i = 0; i < configValues.length; i++) { + for (let j = 0; j < configValues.length; j++) { + for (let showDefaultsValues = 0; showDefaultsValues < 2; showDefaultsValues++) { + if (showDefaultsValues === 0) { + cases.push({ + urlConfig: { + locale: configValues[i], + site: configValues[j], + showDefaults: true + }, + site: defaultSiteMock, + locale: {id: 'en-GB'} + }) + } else { + for (let isDefaultSite = 0; isDefaultSite < 2; isDefaultSite++) { + for (let isDefaultLocale = 0; isDefaultLocale < 2; isDefaultLocale++) { + if (isDefaultSite === 0) { + cases.push({ + urlConfig: { + locale: configValues[i], + site: configValues[j], + showDefaults: false + }, + site: defaultSiteMock, + locale: isDefaultLocale === 0 ? {id: 'en-GB'} : {id: 'fr-FR'} + }) + } else { + cases.push({ + urlConfig: { + locale: configValues[i], + site: configValues[j], + showDefaults: false + }, + site: nonDefaultSiteMock, + locale: isDefaultLocale === 0 ? {id: 'en-US'} : {id: 'fr-FR'} + }) + } + } + } + } + } } - ] - - cases.forEach(({urlConfig, site, locale, expectedRes}) => { - test(`return expected URL with site ${site.alias}, locale ${ - locale.id - } and urlConfig as ${JSON.stringify(urlConfig)}`, () => { - getUrlConfig.mockImplementation(() => urlConfig) - const homeUrl = homeUrlBuilder('/', { - site, - locale + } + + const paths = ['/testpath', '/'] + const expectedResults = (path) => { + return path !== '/' + ? [ + `/uk/en-GB${path}`, + `${path}`, + `/fr-FR${path}`, + `/us${path}`, + `/us/fr-FR${path}`, + `/en-GB${path}?site=uk`, + `${path}`, + `/fr-FR${path}`, + `${path}?site=us`, + `/fr-FR${path}?site=us`, + `/en-GB${path}`, + `${path}`, + `/fr-FR${path}`, + `${path}`, + `/fr-FR${path}`, + `/uk${path}?locale=en-GB`, + `${path}`, + `${path}?locale=fr-FR`, + `/us${path}`, + `/us${path}?locale=fr-FR`, + `${path}?site=uk&locale=en-GB`, + `${path}`, + `${path}?locale=fr-FR`, + `${path}?site=us`, + `${path}?site=us&locale=fr-FR`, + `${path}?locale=en-GB`, + `${path}`, + `${path}?locale=fr-FR`, + `${path}`, + `${path}?locale=fr-FR`, + `/uk${path}`, + `${path}`, + `${path}`, + `/us${path}`, + `/us${path}`, + `${path}?site=uk`, + `${path}`, + `${path}`, + `${path}?site=us`, + `${path}?site=us`, + `${path}`, + `${path}`, + `${path}`, + `${path}`, + `${path}` + ] + : [ + `${path}`, + `${path}`, + `/fr-FR${path}`, + `/us${path}`, + `/us/fr-FR${path}`, + `${path}`, + `${path}`, + `/fr-FR${path}`, + `${path}?site=us`, + `/fr-FR${path}?site=us`, + `${path}`, + `${path}`, + `/fr-FR${path}`, + `${path}`, + `/fr-FR${path}`, + `${path}`, + `${path}`, + `${path}?locale=fr-FR`, + `/us${path}`, + `/us${path}?locale=fr-FR`, + `${path}`, + `${path}`, + `${path}?locale=fr-FR`, + `${path}?site=us`, + `${path}?site=us&locale=fr-FR`, + `${path}`, + `${path}`, + `${path}?locale=fr-FR`, + `${path}`, + `${path}?locale=fr-FR`, + `${path}`, + `${path}`, + `${path}`, + `/us${path}`, + `/us${path}`, + `${path}`, + `${path}`, + `${path}`, + `${path}?site=us`, + `${path}?site=us`, + `${path}`, + `${path}`, + `${path}`, + `${path}`, + `${path}` + ] + } + paths.forEach((path) => { + cases.forEach(({urlConfig, site, locale}, index) => { + test(`URL template path:${path}, site:${site.alias}, locale:${ + locale.id + } and urlConfig:${JSON.stringify(urlConfig)}`, () => { + const buildUrl = createUrlTemplate({url: urlConfig}, site.id, locale.id) + const resultUrl = buildUrl(path, mockConfig.app.siteAliases[site.id], locale.id) + + expect(resultUrl).toEqual(expectedResults(path)[index]) }) - expect(homeUrl).toEqual(expectedRes) }) }) }) @@ -330,94 +358,6 @@ describe('removeQueryParamsFromPath test', () => { }) }) -describe('buildPathWithUrlConfig', () => { - test('return a new url with locale and site a part of path', () => { - getUrlConfig.mockImplementation(() => ({ - locale: 'path', - site: 'path', - showDefaults: true - })) - const url = buildPathWithUrlConfig('/women/dresses', {locale: 'en-GB', site: 'uk'}) - expect(url).toEqual('/uk/en-GB/women/dresses') - }) - - test('return an expected url with no site, no locale for default values when the showDefaults is off', () => { - getUrlConfig.mockImplementation(() => ({ - locale: 'path', - site: 'path', - showDefaults: false - })) - const url = buildPathWithUrlConfig('/women/dresses', {locale: 'en-GB', site: 'uk'}) - expect(url).toEqual('/women/dresses') - }) - - test('return a new url with locale value as a query param and site in the path', () => { - getUrlConfig.mockImplementation(() => ({ - locale: 'query_param', - site: 'path', - showDefaults: true - })) - const url = buildPathWithUrlConfig('/women/dresses', {locale: 'en-GB', site: 'uk'}) - expect(url).toEqual('/uk/women/dresses?locale=en-GB') - }) - - test('return a new url with locale value as a path, site as query_param', () => { - getUrlConfig.mockImplementation(() => ({ - locale: 'path', - site: 'query_param', - showDefaults: true - })) - const url = buildPathWithUrlConfig('/women/dresses', {locale: 'en-GB', site: 'uk'}) - expect(url).toEqual('/en-GB/women/dresses?site=uk') - }) - - test('return a new url with locale value as a path, site as query_param when showDefault is off', () => { - getUrlConfig.mockImplementation(() => ({ - locale: 'path', - site: 'query_param', - showDefaults: false - })) - const url = buildPathWithUrlConfig('/women/dresses', {locale: 'en-GB', site: 'uk'}) - expect(url).toEqual('/women/dresses') - }) - - test('return a new url without a disallow param but respect other params', () => { - getUrlConfig.mockImplementation(() => ({ - locale: 'query_param', - site: 'path', - showDefaults: true - })) - const url = buildPathWithUrlConfig( - '/women/dresses?something=else&refine=c_color', - {locale: 'en-GB', site: 'uk'}, - {disallowParams: ['refine']} - ) - expect(url).toEqual('/uk/women/dresses?something=else&locale=en-GB') - }) - - test('return a new url as configured when the values are not defaults and showDefault is off', () => { - getUrlConfig.mockImplementation(() => ({ - locale: 'query_param', - site: 'path', - showDefaults: false - })) - const url = buildPathWithUrlConfig( - '/women/dresses?something=else&refine=c_color', - {locale: 'en-CA', site: 'us'}, - {disallowParams: ['refine']} - ) - expect(url).toEqual('/us/women/dresses?something=else&locale=en-CA') - }) - - test('throw an error when url config is not defined', () => { - getUrlConfig.mockImplementation(() => undefined) - - expect(() => { - buildPathWithUrlConfig('/women/dresses', {locale: 'en-GB', site: 'uk'}) - }).toThrow() - }) -}) - describe('absoluteUrl', function() { test('return expected when path is a relative url', () => { const url = absoluteUrl('/uk/en/women/dresses') diff --git a/packages/template-retail-react-app/config/mocks/default.js b/packages/template-retail-react-app/config/mocks/default.js index 2a52309110..e160c5734a 100644 --- a/packages/template-retail-react-app/config/mocks/default.js +++ b/packages/template-retail-react-app/config/mocks/default.js @@ -29,6 +29,7 @@ module.exports = { id: 'site-1', l10n: { defaultLocale: 'en-GB', + defaultCurrency: 'GBP', supportedLocales: [ { id: 'en-GB', @@ -50,6 +51,7 @@ module.exports = { id: 'site-2', l10n: { defaultLocale: 'en-US', + defaultCurrency: 'USD', supportedLocales: [ { id: 'en-US',