Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
13 changes: 11 additions & 2 deletions packages/extension-chakra-store-locator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ If you want to use this without having to install `@chakra-ui` in your project,

### `@salesforce/commerce-sdk-react` Provider

This extension uses the `@salesforce/commerce-sdk-react` package to fetch the store locator data from SCAPI. Your application must use the `CommerceApiProvider` in the React component tree.
This extension uses the `@salesforce/commerce-sdk-react` package to fetch the store locator data from SCAPI. If you provide a `commerceApi` configuration in the extension config, the `CommerceApiProvider` will be added to the React component tree as the default provider. If you already have a `CommerceApiProvider` in your application, do not include the `commerceApi` configuration in the extension config.

## Configurations

Expand Down Expand Up @@ -66,7 +66,16 @@ The Store Locator extension is configured via the `mobify.app.extensions` proper
"countryCode": "DE",
"countryName": "Germany"
}
]
],
"commerceApi": {
"proxyPath": "/mobify/proxy/api",
"parameters": {
"shortCode": "8o7m175y",
"clientId": "c9c45bfd-0ed3-4aa2-9971-40f88962b836",
"organizationId": "f_ecom_zzrf_001",
"siteId": "RefArchGlobal"
}
}
}
]
]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright (c) 2025, 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, screen} from '@testing-library/react'
import {withOptionalCommerceSdkReactProvider} from './with-optional-commerce-sdk-react-provider'
import PropTypes from 'prop-types'

jest.mock('@salesforce/commerce-sdk-react', () => ({
useCommerceApi: jest.fn(),
// eslint-disable-next-line react/prop-types
CommerceApiProvider: ({children}) => {
return <div data-testid="commerce-provider">{children}</div>
}
}))

jest.mock('@salesforce/pwa-kit-react-sdk/utils/url', () => ({
getAppOrigin: jest.fn(() => 'https://example.com')
}))

describe('withOptionalCommerceSdkReactProvider', () => {
const TestComponent = () => <div>Test Component</div>
const mockConfig = {
commerceApi: {
parameters: {
shortCode: 'test',
clientId: 'test-client',
organizationId: 'test-org',
siteId: 'test-site',
locale: 'en-US',
currency: 'USD'
},
proxyPath: '/api'
}
}

beforeEach(() => {
jest.clearAllMocks()
})

it('wraps component with CommerceApiProvider when no provider exists', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {useCommerceApi} = require('@salesforce/commerce-sdk-react')
useCommerceApi.mockImplementation(() => {
throw new Error('No provider')
})

const WrappedComponent = withOptionalCommerceSdkReactProvider(TestComponent, mockConfig)
const {container} = render(<WrappedComponent />)

expect(screen.getByTestId('commerce-provider')).toBeTruthy()
expect(screen.getByText('Test Component')).toBeTruthy()
})

it('does not wrap component when provider already exists', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {useCommerceApi} = require('@salesforce/commerce-sdk-react')
useCommerceApi.mockReturnValue({ShopperProducts: {}, ShopperBaskets: {}})

const WrappedComponent = withOptionalCommerceSdkReactProvider(TestComponent, mockConfig)
const {container} = render(<WrappedComponent />)

expect(container.querySelector('[data-testid="commerce-provider"]')).toBeNull()
expect(screen.getByText('Test Component')).toBeTruthy()
})

it('passes props to wrapped component', () => {
const TestComponentWithProps = ({testProp}) => <div>{testProp}</div>
TestComponentWithProps.propTypes = {
testProp: PropTypes.string
}

// eslint-disable-next-line @typescript-eslint/no-var-requires
const {useCommerceApi} = require('@salesforce/commerce-sdk-react')
useCommerceApi.mockImplementation(() => {
throw new Error('No provider')
})

const WrappedComponent = withOptionalCommerceSdkReactProvider(
TestComponentWithProps,
mockConfig
)
render(<WrappedComponent testProp="test value" />)

expect(screen.getByText('test value')).toBeTruthy()
})

it('renders wrapped component without provider when config is missing', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {useCommerceApi} = require('@salesforce/commerce-sdk-react')
useCommerceApi.mockImplementation(() => {
throw new Error('No provider')
})

const invalidConfig = {}
const WrappedComponent = withOptionalCommerceSdkReactProvider(TestComponent, invalidConfig)
const {container} = render(<WrappedComponent />)

expect(container.querySelector('[data-testid="commerce-provider"]')).toBeNull()
expect(screen.getByText('Test Component')).toBeTruthy()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2025, 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 {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url'
import {CommerceApiProvider, useCommerceApi} from '@salesforce/commerce-sdk-react'
import {UserConfig} from '../types/config'
import {logger} from '../logger'

/**
* Checks if the CommerceApiProvider is already installed in the component tree.
* @returns boolean, true if the CommerceApiProvider is installed, false otherwise.
*/
const useHasCommerceApiProvider = () => {
let hasProvider = false

try {
const api = useCommerceApi()

// the api object is an object with a bunch of api clients like ShopperProduct, ShopperOrder, etc.
// if the object is empty, then the CommerceApiProvider is not installed
if (Object.keys(api).length > 0) {
hasProvider = true
}
} catch (_) {
hasProvider = false
}

return hasProvider
}

type WithOptionalCommerceSdkReactProvider = React.ComponentPropsWithoutRef<any>
Copy link
Contributor

Choose a reason for hiding this comment

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

I know this comment isn't related to what you are implementing in your PR.. but seeing any triggered me to ask a question.

Copy link
Contributor Author

@kevinxh kevinxh Jan 13, 2025

Choose a reason for hiding this comment

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

I'm not sure if there is a better option for this, maybe I should use unknown? since the underlying wrapped component props is unknown?


/**
* Higher-order component that conditionally installs the CommerceApiProvider if the config is provided.
*
* @param WrappedComponent - The component to be optionally wrapped with CommerceApiProvider.
* @param config - The configuration object for the CommerceApiProvider.
* @returns A component that wraps the given component with CommerceApiProvider if it is not already present in the component tree.
*/
export const withOptionalCommerceSdkReactProvider = <P extends object>(
Copy link
Contributor

Choose a reason for hiding this comment

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

Not required, but if this is going to be a pattern that we intend on suggesting extension developers use, then it might be worth while to create a utility that will take a provider component and a hook that is used to determine if there is a preexisting provider already and spit out an optional provider. Something like this:

import {withOptional} from '@salesforce/pwa-kit-react-sdk/...'
import {useCommerceApi} from '@salesforce/commerce-sdk-react'

const withOptionalCommerceSdkReactprovider = withOptional(CommerceApiProvider, {assertionHook: useCommerceApi})

Where the first arg is the component that you want to optionally wrap and assertionHook option is a hook that will best tested to see if it returns a truthy value.

WrappedComponent: React.ComponentType<P>,
config: UserConfig
) => {
const HOC: React.FC<P> = (props: WithOptionalCommerceSdkReactProvider) => {
if (useHasCommerceApiProvider()) {
return <WrappedComponent {...(props as P)} />
}
if (!config.commerceApi || !config.commerceApi?.parameters) {
logger.error(
'CommerceApiProvider is not installed and no commerceApi config is provided, this extension may not work as expected.'
)
return <WrappedComponent {...(props as P)} />
}
const appOrigin = getAppOrigin()
return (
<CommerceApiProvider
shortCode={config.commerceApi.parameters.shortCode}
clientId={config.commerceApi.parameters.clientId}
organizationId={config.commerceApi.parameters.organizationId}
siteId={config.commerceApi.parameters.siteId}
locale={config.commerceApi.parameters.locale}
currency={config.commerceApi.parameters.currency}
redirectURI={`${appOrigin}/callback`}
proxy={`${appOrigin}${config.commerceApi.proxyPath}`}
>
<WrappedComponent {...(props as P)} />
</CommerceApiProvider>
)
}

return HOC
}
9 changes: 9 additions & 0 deletions packages/extension-chakra-store-locator/src/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* Copyright (c) 2025, 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 createLogger from '@salesforce/pwa-kit-runtime/utils/logger-factory'

export const logger = createLogger({packageName: 'extension-chakra-store-locator'})
15 changes: 12 additions & 3 deletions packages/extension-chakra-store-locator/src/setup-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ import {RouteProps} from 'react-router-dom'

// Platform Imports
import {ApplicationExtension} from '@salesforce/pwa-kit-extension-sdk/react'
import {applyHOCs} from '@salesforce/pwa-kit-extension-sdk/react/utils'

// Local Imports
import {withOptionalChakra} from './components/with-optional-chakra-provider'
import {withOptionalCommerceSdkReactProvider} from './components/with-optional-commerce-sdk-react-provider'
import {withStoreLocator} from './components/with-store-locator'
import {Config} from './types'

import StoreLocatorPage from './pages/store-locator'
import {logger} from './logger'
import extensionMeta from '../extension-meta.json'

class StoreLocatorExtension extends ApplicationExtension<Config> {
Expand All @@ -29,13 +32,19 @@ class StoreLocatorExtension extends ApplicationExtension<Config> {
const config = this.getConfig()

if (!config.supportedCountries || config.supportedCountries.length === 0) {
// TODO: use our logger
console.warn(
logger.error(
'[extension-chakra-store-locator] Missing supportedCountries, this extension will not work.'
)
}

return withStoreLocator(withOptionalChakra(App), config)
const HOCs = [
(component: React.ComponentType<any>) => withStoreLocator(component, config),
(component: React.ComponentType<any>) =>
withOptionalCommerceSdkReactProvider(component, config),
(component: React.ComponentType<any>) => withOptionalChakra(component)
]
Comment on lines +40 to +45
Copy link
Contributor

Choose a reason for hiding this comment

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

👍


return applyHOCs(App, HOCs)
}

extendRoutes(routes: RouteProps[]): RouteProps[] {
Expand Down
11 changes: 11 additions & 0 deletions packages/extension-chakra-store-locator/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ export interface UserConfig extends ApplicationExtensionConfig {
countryCode: string
countryName: string
}>
commerceApi?: {
proxyPath: string
parameters: {
shortCode: string
clientId: string
organizationId: string
siteId: string
locale: string
currency: string
}
}
Comment on lines +24 to +34
Copy link
Contributor

Choose a reason for hiding this comment

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

👍

}

/**
Expand Down
39 changes: 39 additions & 0 deletions packages/template-typescript-minimal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@loadable/component": "^5.15.3",
"@salesforce/extension-chakra-store-locator": "^0.1.0-extensibility-preview.3",
"@salesforce/pwa-kit-dev": "4.0.0-extensibility-preview.3",
"@salesforce/pwa-kit-extension-sdk": "4.0.0-extensibility-preview.3",
"@salesforce/pwa-kit-react-sdk": "4.0.0-extensibility-preview.3",
Expand All @@ -44,6 +45,44 @@
"npm": "^8.0.0 || ^9.0.0 || ^10.0.0"
},
"mobify": {
"app": {
"extensions": [
[
"@salesforce/extension-chakra-store-locator",
{
"enabled": true,
"path": "/store-locator",
"radius": 100,
"radiusUnit": "km",
"defaultPageSize": 10,
"defaultPostalCode": "10178",
"defaultCountry": "Germany",
"defaultCountryCode": "DE",
"supportedCountries": [
{
"countryCode": "US",
"countryName": "United States"
},
{
"countryCode": "DE",
"countryName": "Germany"
}
],
"commerceApi": {
"proxyPath": "/mobify/proxy/api",
"parameters": {
"shortCode": "8o7m175y",
"clientId": "c9c45bfd-0ed3-4aa2-9971-40f88962b836",
"organizationId": "f_ecom_zzrf_001",
"siteId": "RefArchGlobal",
"locale": "en-GB",
"currency": "USD"
}
}
}
]
]
},
"ssrEnabled": true,
"ssrOnly": [
"ssr.js",
Expand Down
Loading