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

[App Extensibility ⚙️] Extension Communication via State @@W-17447843@@ #2214

Open
wants to merge 8 commits into
base: feature/extensibility-v2
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2024, 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 {useApplicationExtensionsStore} from '@salesforce/pwa-kit-extension-sdk/react'
import extensionMeta from '../../extension-meta.json'

/**
* This hook returns the store for the current application extension.
*/
export const useExtensionStore = (defaultValue: any = {}) => {
const state = useApplicationExtensionsStore((state: Record<string, any>) => state.state[extensionMeta.id] || defaultValue)

if (state === undefined) {
throw new Error(
`'useExtensionStore' could not find your current application extension state! Make sure you have added the store to your extension.`
)
}

return state
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,22 @@

import React from 'react'
import {Box, Container} from '@chakra-ui/react'

import {StoreLocatorContent} from '../../components/content'
import {useExtensionStore} from '../../hooks/use-extension-store'

const StoreLocatorPage = () => {
const {incrementCounter, decrementCounter, counter} = useExtensionStore()

return (
<Box data-testid="store-locator-page" bg="gray.50" py={[8, 16]}>
<span>Count: {counter}</span><br/>
<button onClick={() => incrementCounter()}>
[+]
</button>
<button onClick={() => decrementCounter()}>
[-]
</button>
<Container
overflowY="scroll"
paddingTop={8}
Expand Down
22 changes: 20 additions & 2 deletions packages/extension-chakra-store-locator/src/setup-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import React from 'react'
import {RouteProps} from 'react-router-dom'

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

// Local Imports
Expand All @@ -23,6 +23,7 @@ import StoreLocatorPage from './pages/store-locator'
import {logger} from './logger'
import extensionMeta from '../extension-meta.json'


class StoreLocatorExtension extends ApplicationExtension<Config> {
static readonly id = extensionMeta.id

Expand All @@ -37,11 +38,28 @@ class StoreLocatorExtension extends ApplicationExtension<Config> {
)
}

const withApplicationExtensionStoreOptions = {
id: extensionMeta.id,
sliceInitializer: (set: any, get: any) => ({
// TODO: Kevin, this is where you are going to place your initial state and actions. E.g. "modalOpen: false" etc.
counter: 0,
incrementCounter: () =>
set((state: any) => ({
counter: state.counter + 1
})),
decrementCounter: () =>
set((state: any) => ({
counter: state.counter - 1
}))
})
}

const HOCs = [
(component: React.ComponentType<any>) => withStoreLocator(component, config),
(component: React.ComponentType<any>) =>
withOptionalCommerceSdkReactProvider(component, config),
(component: React.ComponentType<any>) => withOptionalChakra(component)
(component: React.ComponentType<any>) => withOptionalChakra(component),
(component: React.ComponentType<any>) => withApplicationExtensionStore(component, withApplicationExtensionStoreOptions)
]

return applyHOCs(App, HOCs)
Expand Down
33 changes: 33 additions & 0 deletions packages/extension-starter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,39 @@ the extension as they like.
> Congratulations! The Sample extension was successfully installed! Please visit https://www.npmjs.com/package/@salesforce/extension-starter for more information on how to use this extension.
```

# State Management

By default all extensions are enhanced with state management using the `withApplicationExtensionStore` higher-order component. Under the hood
the state is provided using [Zustand](https://www.npmjs.com/package/zustand) as a global store for the entire PWA-Kit application.
Each Application Extension inserts a "slice" into this global store following the
[slicing pattern](https://github.com/pmndrs/zustand/blob/37e1e3f193a5e5dec6fbd0f07514aec59a187e01/docs/guides/slices-pattern.md).
This allows you to have data separation from one extension to the other when it's important, but also allows you to access state its
associated actions of other extensions when needed.

An examples of why you might want to access state and action from another extension would be opening a store-locator map modal provided via
the store-locator extension from other pages like the storefronts toolbar or the base project.

This is how you would do something like this.

```
// /base-project/app/components/my-component.jsx
import {useExtensionsStore} from '@salesforce/pwa-kit-extension-sdk/react'

export MyComponent = () => {
// Grab the slice of the extension state for "extension-a"
const {toggleMapsModal} = useExtensionsStore(
(state) =>
state.state['@salesforce/extension-store-locator'] || {}
)

return (
<div>
<button onClick={() => toggleMapsModal()}/>
</div>
)
}
```

# Advanced Usage

In order to customize this Application Extension to your particular needs we suggest that you refer to the section titled
Expand Down
24 changes: 24 additions & 0 deletions packages/extension-starter/src/hooks/use-extension-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2024, 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 {useApplicationExtensionsStore} from '@salesforce/pwa-kit-extension-sdk/react'
import extensionMeta from '../../extension-meta.json'

/**
* This hook returns the store for the current application extension.
*/
export const useExtensionStore = () => {
const state = useApplicationExtensionsStore((state: Record<string, any>) => state.state[extensionMeta.id])

if (state === undefined) {
throw new Error(
`'useExtensionStore' could not find your current application extension state! Make sure you have added the store to your extension.`
)
}

return state
}
29 changes: 27 additions & 2 deletions packages/extension-starter/src/setup-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import React from 'react'
import {RouteProps} from 'react-router-dom'

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

// Local Imports
import {Config} from './types'
Expand All @@ -36,7 +37,31 @@ class Sample extends ApplicationExtension<Config> {
extendApp<T extends React.ComponentType<T>>(
App: React.ComponentType<T>
): React.ComponentType<T> {
return sampleHOC(App)

const HOCs = [
sampleHOC,
// NOTE: Add state management to the application extension. If your extension does not use state management, you can remove
// the use of `withApplicationExtensionStore`.
// Please refer to the "state management" section of the readme for more information on how to use this feature.
(component: React.ComponentType<any>) =>
withApplicationExtensionStore(
component,
{
id: extensionMeta.id,
sliceInitializer: (set: any, get: any) => ({
sampleStateValue: 0,
sampleAction: () =>
{
set((state: any) => ({
counter: state.sampleStateValue + 1
}))
}
})
}
)
]

return applyHOCs(App, HOCs)
}

/**
Expand Down
43 changes: 36 additions & 7 deletions packages/pwa-kit-extension-sdk/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/pwa-kit-extension-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"handlebars": "^4.7.8",
"hoist-non-react-statics": "^3.3.2",
"lodash.merge": "^4.6.2",
"resolve": "^1.22.8"
"resolve": "^1.22.8",
"zustand": "5.0.3"
},
"devDependencies": {
"@babel/core": "^7.21.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,8 @@ export class ApplicationExtension<
public beforeRouteMatch(routes: RouteProps[]): RouteProps[] {
return routes
}

public getSliceInitializer(): any {
return () => ({})
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/*
* Copyright (c) 2024, salesforce.com, inc.
* 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
*/

export {default as withApplicationExtensions} from './withApplicationExtensions'
export {default as withApplicationExtensionStore} from './withApplicationExtensionStore'
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2024, 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
*/

// Third-Party
import React from 'react'

// Local
import {useApplicationExtensionsStore} from '../hooks/useApplicationExtensionsStore'

// Local Types
import {SliceInitializer} from '../hooks/useApplicationExtensionsStore'

// Local Types
type withStoreOptions = {
id: string
sliceInitializer: SliceInitializer<any>
}

/**
* This HOC is used to add a slice to the global store for an extension.
*
* @param WrappedComponent
* @param options
* @returns
*/
const withApplicationExtensionStore = <C,>(
WrappedComponent: React.ComponentType<C>,
options: withStoreOptions
) => {
const {id, sliceInitializer} = options

// Because there extensions have unique slice names, we can safely add them to the global store.
useApplicationExtensionsStore.getState().addSlice(id, sliceInitializer)

// Return the original component as we aren't modifying it in any way... yet.
return WrappedComponent
}

export default withApplicationExtensionStore
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,16 @@ const withApplicationExtensions = <C,>(
WrappedComponent: React.ComponentType<C>,
options: withApplicationExtensionsOptions
) => {
const hocs: GenericHocType<C>[] = options.applicationExtensions
const {applicationExtensions} = options

// Get all application extension higher-order components (HOCs)
const hocs: GenericHocType<C>[] = applicationExtensions
.filter((applicationExtension) => applicationExtension.isEnabled())
// It's counterintuitive: reversing the list is necessary, as we build the React tree from innermost
.reverse()
.map((extension) => extension.extendApp.bind(extension) as GenericHocType<C>)
.filter(Boolean)

const withApplicationExtensionsProvider: GenericHocType<any> = (WrappedComponent) => {
const WithApplicationExtensionsProvider = (props: any) => (
<ApplicationExtensionsProvider extensions={options.applicationExtensions}>
Expand Down
Loading
Loading