Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Back-port Shopper Experience Base Components into Retail Template #992

Merged
merged 5 commits into from
Feb 17, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ class CommerceAPI {
sendLocale: false,
sendCurrency: ['createBasket']
},
shopperExperience: {
api: sdk.ShopperExperience
},
shopperGiftCertificates: {
api: sdk.ShopperGiftCertificates
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2023, Salesforce, 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 PropTypes from 'prop-types'
import {usePageContext} from '../page'

/**
* This component will render a page designer page given its serialized data object.
*
* @param {PageProps} props
* @param {Component} props.component - The page designer component data representation.
* @returns {React.ReactElement} - Experience component.
*/
export const Component = ({component}) => {
const pageContext = usePageContext()
const ComponentClass =
pageContext?.components[component.typeId] ||
(({typeId}) => <div>{`Component type '${typeId}' not found!`}</div>)
const {data, ...rest} = component
return (
<div id={component.id} className="component">
<div className="container">
<ComponentClass {...rest} {...data} />
</div>
</div>
)
}

Component.displayName = 'Component'

Component.propTypes = {
component: PropTypes.object.isRequired
}

export default Component
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2023, Salesforce, 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 {render} from '@testing-library/react'
import Component from './index'
import {PageContext} from '../Page'

const SAMPLE_COMPONENT = {
id: 'rfdvj4ojtltljw3',
typeId: 'commerce_assets.carousel',
data: {
title: 'Topseller',
category: 'topseller'
},
regions: [
{
id: 'regionB1',
components: [
{
id: 'rfdvj4ojtltljw3',
typeId: 'commerce_assets.carousel',
data: {
title: 'Topseller',
category: 'topseller'
}
}
]
}
]
}

const TEST_COMPONENTS = {
['commerce_assets.carousel']: () => <div className="carousel">Carousel</div>
}

test('Page throws if used outside of a Page component', () => {
expect(() => render(<Component component={SAMPLE_COMPONENT} />)).toThrow()
})

test('Page renders correct component', () => {
const component = <Component component={SAMPLE_COMPONENT} />

const {container} = render(component, {
// eslint-disable-next-line react/display-name
wrapper: () => (
<PageContext.Provider value={{components: TEST_COMPONENTS}}>
{component}
</PageContext.Provider>
)
})

// Component are in document.
expect(container.querySelectorAll('.component')?.length).toEqual(1)

// Prodived components are in document. (Note: Sub-regions/components aren't rendered because that is
// the responsibility of the component definition.)
expect(container.querySelectorAll('.carousel')?.length).toEqual(1)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2023, Salesforce, 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, {useContext, useEffect, useState} from 'react'
import PropTypes from 'prop-types'
import {Helmet} from 'react-helmet'
import {Region} from '../region'

// This context will hold the component map as well as any other future context.
export const PageContext = React.createContext(undefined)

// This hook allows sub-components to use the page context. In our case we use it
// so that the generic <Component /> can use the component map to know which react component
// to render.
export const usePageContext = () => {
const value = useContext(PageContext)

if (!value) {
throw new Error('"usePageContext" cannot be used outside of a page component.')
}

return value
}

/**
* This component will render a page designer page given its serialized data object.
*
* @param {PageProps} props
* @param {Page} props.region - The page designer page data representation.
* @param {ComponentMap} props.components - A mapping of typeId's to react components representing the type.
* @returns {React.ReactElement} - Page component.
*/
export const Page = (props) => {
const {page, components, className = '', ...rest} = props
const [contextValue, setContextValue] = useState({components})
const {id, regions, pageDescription, pageKeywords, pageTitle} = page || {}

// NOTE: This probably is not required as the list of components is known at compile time,
// but we might need this ability in the future if we are to lazy load components.
useEffect(() => {
setContextValue({
...contextValue,
components
})
}, [components])

return (
<PageContext.Provider value={contextValue}>
<Helmet>
{pageTitle && <title>{pageTitle}</title>}
{pageDescription && <meta name="description" content={pageDescription} />}
{pageKeywords && <meta name="keywords" content={pageKeywords} />}
</Helmet>
<div id={id} className={`page ${className}`} {...rest}>
<div className="container">
{regions?.map((region) => (
<Region key={region.id} region={region} />
))}
</div>
</div>
</PageContext.Provider>
)
}

Page.displayName = 'Page'

Page.propTypes = {
page: PropTypes.object.isRequired,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will the API response always include a page key?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No it wont. It's up to the developer to take the page returned from the API and pass it to the page component under the prop "page". I did this because there will potentially be other properties, for example, if we decide to add some kind of edit mode you might have a property called "editing". Also we are passing in className, and other props that get forwarded to the main container.

components: PropTypes.object.isRequired,
className: PropTypes.string
}

export default Page
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2023, Salesforce, 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 {render} from '@testing-library/react'
import Page from './index'
import {Helmet} from 'react-helmet'

const SAMPLE_PAGE = {
id: 'samplepage',
typeId: 'storePage',
aspectTypeId: 'pdpAspect',
name: 'Sample Page',
description: 'Sample page of the storefront.',
pageTitle: 'title',
pageDescription: 'description',
pageKeywords: 'keywords',
regions: [
{
id: 'regionA',
components: [
{
id: 'iofwj38fhw3f',
typeId: 'commerce_assets.banner',
data: {
title: 'Products On Sale',
bannerImage: 'sale/topsellerPromo.jpg'
}
}
]
},
{
id: 'regionB',
components: [
{
id: 'rfdvj4ojtltljw3',
typeId: 'commerce_assets.carousel',
data: {
title: 'Topseller',
category: 'topseller'
},
regions: [
{
id: 'regionB1',
components: [
{
id: 'rfdvj4ojtltljw3',
typeId: 'commerce_assets.carousel',
data: {
title: 'Topseller',
category: 'topseller'
}
}
]
}
]
}
]
},
{
id: 'regionC',
components: []
}
]
}

test('Page renders without errors', () => {
const {container} = render(<Page page={SAMPLE_PAGE} components={{}} />)

// Page is in document.
expect(container.querySelector('[id=samplepage]')).toBeInTheDocument()

// Meta data and title are set
const helmet = Helmet.peek()
expect(helmet.title).toEqual('title')
expect(
helmet.metaTags.find(
({name, content}) => name === 'description' && content === 'description'
)
).toBeTruthy()
expect(
helmet.metaTags.find(({name, content}) => name === 'keywords' && content === 'keywords')
).toBeTruthy()

// Regions are in document.
expect(container.querySelectorAll('.region')?.length).toEqual(3)

// Components are in document. (Note: Sub-regions/components aren't rendered because that is
// the responsibility of the component definition.)
expect(container.querySelectorAll('.component')?.length).toEqual(2)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2023, Salesforce, 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 PropTypes from 'prop-types'
import {Component} from '../component'

/**
* This component will render a page designer region given its serialized data object.
*
* @param {RegionProps} props
* @param {Region} props.region - The page designer region data representation.
* @returns {React.ReactElement} - Region component.
*/
export const Region = (props) => {
const {region, className = '', ...rest} = props
const {id, components} = region

return (
<div id={id} className={`region ${className}`} {...rest}>
<div className="container">
{components?.map((component) => (
<Component key={component.id} component={component} />
))}
</div>
</div>
)
}

Region.displayName = 'Region'

Region.propTypes = {
region: PropTypes.object.isRequired,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the API response use regions plural, not region?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pages "regions" are mapped over in the page component itself. I chose originally to make the components singular, I believe this is more versatile as developers could potentially use the Region component on it's own, instead of having to render all the regions.

className: PropTypes.string
}

export default Region
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2023, Salesforce, 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 {render} from '@testing-library/react'
import Region from './index'
import {PageContext} from '../Page'

const SAMPLE_REGION = {
id: 'regionB',
components: [
{
id: 'rfdvj4ojtltljw3',
typeId: 'commerce_assets.carousel',
data: {
title: 'Topseller',
category: 'topseller'
},
regions: [
{
id: 'regionB1',
components: [
{
id: 'rfdvj4ojtltljw3',
typeId: 'commerce_assets.carousel',
data: {
title: 'Topseller',
category: 'topseller'
}
}
]
}
]
}
]
}

test('Region throws if used outside of a Page component', () => {
expect(() => render(<Region region={SAMPLE_REGION} />)).toThrow()
})

test('Region renders without errors', () => {
const component = <Region region={SAMPLE_REGION} />

const {container} = render(component, {
// eslint-disable-next-line react/display-name
wrapper: () => (
<PageContext.Provider value={{components: {}}}>{component}</PageContext.Provider>
)
})

// Regions are in document.
expect(container.querySelectorAll('.region')?.length).toEqual(1)

// Components are in document. (Note: Sub-regions/components aren't rendered because that is
// the responsibility of the component definition.)
expect(container.querySelectorAll('.component')?.length).toEqual(1)
})
Loading