Skip to content

Commit 8c5aeec

Browse files
committed
feat: pwa pd support
1 parent 06c022d commit 8c5aeec

File tree

34 files changed

+1652
-269
lines changed

34 files changed

+1652
-269
lines changed

packages/commerce-sdk-react/src/components/ShopperExperience/Component/index.test.tsx

Lines changed: 111 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,64 +5,125 @@
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77
import React from 'react'
8-
import {render} from '@testing-library/react'
9-
import Component from './index'
10-
import {PageContext} from '../Page'
11-
12-
const SAMPLE_COMPONENT = {
13-
id: 'rfdvj4ojtltljw3',
14-
typeId: 'commerce_assets.carousel',
15-
data: {
16-
title: 'Topseller',
17-
category: 'topseller'
18-
},
19-
regions: [
20-
{
21-
id: 'regionB1',
22-
components: [
23-
{
24-
id: 'rfdvj4ojtltljw3',
25-
typeId: 'commerce_assets.carousel',
26-
data: {
27-
title: 'Topseller',
28-
category: 'topseller'
29-
}
30-
}
31-
]
32-
}
33-
]
34-
}
8+
import {render, screen} from '@testing-library/react'
9+
import {Component, ComponentProps} from './index'
10+
import {registry} from '../registry'
11+
12+
// Mock the registry
13+
jest.mock('../registry', () => ({
14+
registry: {
15+
getComponent: jest.fn(),
16+
getFallback: jest.fn(),
17+
preload: jest.fn()
18+
}
19+
}))
3520

36-
const TEST_COMPONENTS = {
37-
['commerce_assets.carousel']: () => <div className="carousel">Carousel</div>
21+
// Type the mock registry with flexible return types for testing
22+
const mockRegistry = registry as unknown as {
23+
getComponent: jest.Mock
24+
getFallback: jest.Mock
25+
preload: jest.Mock
3826
}
3927

40-
test('Page throws if used outside of a Page component', () => {
41-
// Mock console.error to suppress React error boundary warnings
42-
const originalError = console.error
43-
console.error = jest.fn()
28+
describe('Component', () => {
29+
// Create mock component data - cast to ComponentProps['component'] for test flexibility
30+
const mockComponent = {
31+
id: 'test-component-id',
32+
typeId: 'commerce_assets.banner',
33+
data: {
34+
title: 'Test Banner',
35+
imageUrl: '/test-image.jpg'
36+
},
37+
visible: true,
38+
localized: false,
39+
designMetadata: {
40+
name: 'Test Component'
41+
},
42+
regions: []
43+
} as unknown as ComponentProps['component']
4444

45-
expect(() => {
46-
render(<Component component={SAMPLE_COMPONENT} />)
47-
}).toThrow()
48-
console.error = originalError
49-
})
45+
beforeEach(() => {
46+
jest.clearAllMocks()
47+
// Suppress console.log during tests
48+
jest.spyOn(console, 'log').mockImplementation(() => {})
49+
})
5050

51-
test('Page renders correct component', () => {
52-
const component = <Component component={SAMPLE_COMPONENT} />
51+
afterEach(() => {
52+
jest.restoreAllMocks()
53+
})
5354

54-
const {container} = render(component, {
55-
wrapper: () => (
56-
<PageContext.Provider value={{components: TEST_COMPONENTS}}>
57-
{component}
58-
</PageContext.Provider>
55+
test('renders component when DynamicComponent is available', () => {
56+
const MockDynamicComponent = ({title}: {title: string}) => (
57+
<div data-testid="dynamic-component">{title}</div>
5958
)
59+
mockRegistry.getComponent.mockReturnValue(MockDynamicComponent)
60+
mockRegistry.getFallback.mockReturnValue(null)
61+
62+
render(<Component component={mockComponent} regionId="test-region" />)
63+
64+
expect(screen.getByTestId('dynamic-component')).toBeInTheDocument()
65+
expect(screen.getByText('Test Banner')).toBeInTheDocument()
6066
})
6167

62-
// Component are in document.
63-
expect(container.querySelectorAll('.component')?.length).toBe(1)
68+
test('calls preload when DynamicComponent is not available', () => {
69+
const preloadPromise = Promise.resolve()
70+
mockRegistry.getComponent.mockReturnValue(undefined)
71+
mockRegistry.getFallback.mockReturnValue(null)
72+
mockRegistry.preload.mockReturnValue(preloadPromise)
6473

65-
// Provided components are in document. (Note: Sub-regions/components aren't rendered because that is
66-
// the responsibility of the component definition.)
67-
expect(container.querySelectorAll('.carousel')?.length).toBe(1)
74+
// Component throws the preload promise for Suspense to catch
75+
// We can't test the throw directly because React catches it internally
76+
// Instead we verify preload is called with the correct typeId
77+
try {
78+
render(<Component component={mockComponent} regionId="test-region" />)
79+
} catch (e) {
80+
// Expected - Suspense boundary catches this
81+
}
82+
83+
expect(mockRegistry.preload).toHaveBeenCalledWith('commerce_assets.banner')
84+
})
85+
86+
test('passes correct props to DynamicComponent', () => {
87+
const receivedProps: Record<string, unknown> = {}
88+
const MockDynamicComponent = (props: Record<string, unknown>) => {
89+
Object.assign(receivedProps, props)
90+
return <div data-testid="dynamic-component">Test</div>
91+
}
92+
mockRegistry.getComponent.mockReturnValue(MockDynamicComponent)
93+
mockRegistry.getFallback.mockReturnValue(null)
94+
95+
render(<Component component={mockComponent} regionId="test-region" className="custom-class" />)
96+
97+
expect(receivedProps.title).toBe('Test Banner')
98+
expect(receivedProps.imageUrl).toBe('/test-image.jpg')
99+
expect(receivedProps.className).toBe('custom-class')
100+
expect(receivedProps.regionId).toBe('test-region')
101+
expect(receivedProps.component).toBe(mockComponent)
102+
expect(receivedProps.regions).toEqual([])
103+
expect(receivedProps.designMetadata).toEqual({
104+
name: 'Test Component',
105+
isFragment: false,
106+
isVisible: true,
107+
isLocalized: false,
108+
id: 'test-component-id'
109+
})
110+
})
111+
112+
test('handles component without designMetadata', () => {
113+
const componentWithoutDesignMetadata = {
114+
...mockComponent,
115+
designMetadata: undefined
116+
} as unknown as ComponentProps['component']
117+
const receivedProps: Record<string, unknown> = {}
118+
const MockDynamicComponent = (props: Record<string, unknown>) => {
119+
Object.assign(receivedProps, props)
120+
return <div data-testid="dynamic-component">Test</div>
121+
}
122+
mockRegistry.getComponent.mockReturnValue(MockDynamicComponent)
123+
mockRegistry.getFallback.mockReturnValue(null)
124+
125+
render(<Component component={componentWithoutDesignMetadata} regionId="test-region" />)
126+
127+
expect((receivedProps.designMetadata as {name: string | undefined}).name).toBeUndefined()
128+
})
68129
})
Lines changed: 52 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,58 @@
1-
/*
2-
* Copyright (c) 2023, Salesforce, Inc.
3-
* All rights reserved.
4-
* SPDX-License-Identifier: BSD-3-Clause
5-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6-
*/
7-
import React from 'react'
8-
import {Component as ComponentType} from '../types'
9-
import {usePageContext} from '../Page'
10-
11-
type ComponentProps = {
12-
component: ComponentType
13-
}
14-
15-
const ComponentNotFound = ({typeId}: ComponentType) => (
16-
<div>{`Component type '${typeId}' not found!`}</div>
17-
)
181
/**
19-
* This component will render a page designer page given its serialized data object.
2+
* Copyright 2026 Salesforce, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
209
*
21-
* @param {PageProps} props
22-
* @param {Component} props.component - The page designer component data representation.
23-
* @returns {React.ReactElement} - Experience component.
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
2415
*/
25-
export const Component = ({component}: ComponentProps) => {
26-
const pageContext = usePageContext()
27-
const ComponentClass = pageContext?.components[component.typeId] || ComponentNotFound
28-
const {data, ...rest} = component
29-
return (
30-
<div id={component.id} className="component">
31-
<div className="container">
32-
<ComponentClass {...rest} {...data} />
33-
</div>
34-
</div>
35-
)
16+
import React, { type ReactElement, memo, Suspense } from 'react';
17+
import { registry } from '../registry';
18+
import type { ComponentDesignMetadata } from '@salesforce/storefront-next-runtime/design/react';
19+
import type { ComponentType } from '../types';
20+
21+
export interface ComponentProps {
22+
component: ComponentType;
23+
className?: string;
24+
regionId: string;
3625
}
3726

38-
Component.displayName = 'Component'
3927

40-
export default Component
28+
export const Component = memo(function Component({ component, className, regionId }: ComponentProps): ReactElement {
29+
// Get this component's data promise from context by its ID
30+
const FallbackComponent = registry.getFallback(component.typeId);
31+
const DynamicComponent = registry.getComponent(component.typeId);
32+
33+
if (!DynamicComponent) {
34+
// eslint-disable-next-line @typescript-eslint/only-throw-error
35+
throw registry.preload(component.typeId);
36+
}
37+
38+
const designMetadata: ComponentDesignMetadata = {
39+
name: component.designMetadata?.name,
40+
isFragment: false,
41+
isVisible: Boolean(component.visible),
42+
isLocalized: Boolean(component.localized),
43+
id: component.id,
44+
};
45+
46+
return (
47+
<Suspense fallback={FallbackComponent ? <FallbackComponent {...(component.data ?? {})} /> : <div />}>
48+
<DynamicComponent
49+
{...(component.data ?? {})}
50+
designMetadata={designMetadata}
51+
component={component}
52+
regions={component.regions}
53+
className={className}
54+
regionId={regionId}
55+
/>
56+
</Suspense>
57+
);
58+
});

packages/commerce-sdk-react/src/components/ShopperExperience/Page/index.test.tsx

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,27 @@ import React from 'react'
88
import {render} from '@testing-library/react'
99
import Page from './index'
1010
import {Helmet} from 'react-helmet'
11+
import type {PageWithDesignMetadata} from '../types'
1112

12-
const SAMPLE_PAGE = {
13+
// Mock the Component to avoid registry dependency
14+
jest.mock('../Component', () => ({
15+
Component: ({component}: {component: {id: string; typeId: string}}) => (
16+
<div data-testid={`component-${component.id}`} className="component">
17+
{component.typeId}
18+
</div>
19+
)
20+
}))
21+
22+
// Mock the RegionWrapper
23+
jest.mock('../Region/region-wrapper', () => ({
24+
RegionWrapper: ({children, className}: {children: React.ReactNode; className?: string}) => (
25+
<div className={`region ${className || ''}`}>
26+
{children}
27+
</div>
28+
)
29+
}))
30+
31+
const SAMPLE_PAGE: PageWithDesignMetadata = {
1332
id: 'samplepage',
1433
typeId: 'storePage',
1534
aspectTypeId: 'pdpAspect',
@@ -67,6 +86,15 @@ const SAMPLE_PAGE = {
6786
]
6887
}
6988

89+
beforeEach(() => {
90+
// Suppress console.log during tests
91+
jest.spyOn(console, 'log').mockImplementation(() => {})
92+
})
93+
94+
afterEach(() => {
95+
jest.restoreAllMocks()
96+
})
97+
7098
test('Page renders without errors', () => {
7199
const {container} = render(<Page page={SAMPLE_PAGE} components={{}} />)
72100

@@ -92,3 +120,35 @@ test('Page renders without errors', () => {
92120
// the responsibility of the component definition.)
93121
expect(container.querySelectorAll('.component')?.length).toBe(2)
94122
})
123+
124+
test('Page renders with empty page data', () => {
125+
const emptyPage = {
126+
id: 'emptypage',
127+
regions: []
128+
} as unknown as PageWithDesignMetadata
129+
const {container} = render(<Page page={emptyPage} />)
130+
131+
expect(container.querySelector('[id=emptypage]')).toBeInTheDocument()
132+
expect(container.querySelectorAll('.region')?.length).toBe(0)
133+
})
134+
135+
test('Page renders without meta tags when not provided', () => {
136+
const pageWithoutMeta = {
137+
id: 'nometa',
138+
regions: []
139+
} as unknown as PageWithDesignMetadata
140+
render(<Page page={pageWithoutMeta} />)
141+
142+
const helmet = Helmet.peek()
143+
expect(helmet.title).toBeUndefined()
144+
})
145+
146+
test('Page applies custom className', () => {
147+
const simplePage = {
148+
id: 'simplepage',
149+
regions: []
150+
} as unknown as PageWithDesignMetadata
151+
const {container} = render(<Page page={simplePage} className="custom-page-class" />)
152+
153+
expect(container.querySelector('.page.custom-page-class')).toBeInTheDocument()
154+
})

0 commit comments

Comments
 (0)