-
Notifications
You must be signed in to change notification settings - Fork 146
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
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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, | ||
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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does the API response use There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
}) |
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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.