├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── example ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── App.tsx │ ├── components │ │ ├── Page.tsx │ │ └── Sidebar.tsx │ ├── index.css │ ├── index.tsx │ ├── modules │ │ ├── admin │ │ │ └── components │ │ │ │ ├── AdminSection.tsx │ │ │ │ └── AdminWidget.tsx │ │ └── billing │ │ │ └── components │ │ │ ├── BillingSection.tsx │ │ │ └── BillingWidget.tsx │ ├── pluginStore.ts │ ├── react-app-env.d.ts │ └── setupTests.ts └── tsconfig.json ├── package-lock.json ├── package.json ├── src ├── .eslintrc ├── components │ ├── PluginStoreProvider.test.tsx │ ├── PluginStoreProvider.tsx │ ├── Plugins.test.tsx │ ├── Plugins.tsx │ └── __snapshots__ │ │ └── PluginStoreProvider.test.tsx.snap ├── hooks │ ├── usePlugins.test.tsx │ └── usePlugins.tsx ├── index.tsx ├── react-app-env.d.ts ├── types.tsx ├── typings.d.ts └── utils │ ├── store.test.ts │ └── store.ts ├── tsconfig.json └── tsconfig.test.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | node_modules/ 4 | .snapshots/ 5 | *.min.js -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "standard", 5 | "standard-react", 6 | "plugin:prettier/recommended", 7 | "prettier/standard", 8 | "prettier/react", 9 | "plugin:@typescript-eslint/eslint-recommended" 10 | ], 11 | "env": { 12 | "node": true 13 | }, 14 | "parserOptions": { 15 | "ecmaVersion": 2020, 16 | "ecmaFeatures": { 17 | "legacyDecorators": true, 18 | "jsx": true 19 | } 20 | }, 21 | "settings": { 22 | "react": { 23 | "version": "16" 24 | } 25 | }, 26 | "rules": { 27 | "space-before-function-paren": 0, 28 | "react/prop-types": 0, 29 | "react/jsx-handler-names": 0, 30 | "react/jsx-fragments": 0, 31 | "react/no-unused-prop-types": 0, 32 | "import/export": 0 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | coverage -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "semi": true, 5 | "tabWidth": 2, 6 | "bracketSpacing": true, 7 | "jsxBracketSameLine": false, 8 | "arrowParens": "always", 9 | "trailingComma": "none" 10 | } 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 12 4 | - 10 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Lucio Merotta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-plugins 2 | 3 | > Use your react components as plugins. Register them from anywhere in your application, and render them wherever you want implicitely! 4 | 5 | [![NPM](https://img.shields.io/npm/v/react-plugins.svg)](https://www.npmjs.com/package/react-plugins) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 6 | 7 | **NOTE**: This plugin is still in development, and it's API may change between two versions, check out the releases page for updates. This notice will be removed once the package reaches a stable version 8 | 9 | ## Motivation 10 | 11 | When managing large-scale apps, sometimes it might be needed to render components defined in some part of your app (a module, a particular feature, etc.) in another part of the application. The most concrete example is: 12 | 13 | - Your application has many modules, which can be enabled/disabled 14 | - Your application has a sidebar that displays a small widget for each module enabled 15 | 16 | In this setup, it would clutter the Sidebar component if it needed to require each widget from each module, like this: 17 | 18 | ```tsx 19 | // src/Sidebar.tsx 20 | 21 | import BillingWidget from './modules/billing'; 22 | import UsersWidget from './modules/users'; 23 | import AdminWidget from './modules/admin'; 24 | // ... other widget imports 25 | 26 | const Sidebar = () => ( 27 | <> 28 | 29 | 30 | 31 | {/* ... other widgets */} 32 | 33 | ); 34 | ``` 35 | 36 | With this package, you can simply make your modules register the widgets as plugins in the `sidebar` section, and simply ask the sidebar to display the registered plugins 37 | 38 | ```tsx 39 | // src/modules/billing 40 | PluginStore.registerPlugin('sidebar', BillingWidget, 'billing-widget'); 41 | 42 | // src/modules/users 43 | PluginStore.registerPlugin('sidebar', UsersWidget, 'users-widget'); 44 | 45 | // src/modules/admin 46 | PluginStore.registerPlugin('sidebar', AdminWidget, 'admin-widget'); 47 | 48 | // src/Sidebar.tsx 49 | const Sidebar = () => ; 50 | ``` 51 | 52 | ## Demo 53 | 54 | A basic demo showcasing the main features of this plugin is available at https://lmerotta.github.io/react-plugins/ 55 | Code is located in the `example` folder 56 | 57 | ## Install 58 | 59 | ```bash 60 | npm install --save react-plugins 61 | ``` 62 | 63 | ## Features 64 | 65 | - Render components outside of the current parent component 66 | - Register components at runtime 67 | - Supports lazy loading 68 | 69 | ## Example Usage 70 | 71 | Create a pluginStore 72 | 73 | ```ts 74 | // src/pluginStore.ts 75 | import { PluginStore } from 'react-plugins'; 76 | 77 | const store = new PluginStore(); 78 | 79 | export default store; 80 | ``` 81 | 82 | Define a component as you'd normally do 83 | 84 | ```tsx 85 | // src/MyComponent.tsx 86 | const MyComponent = () =>
Hello, world!
; 87 | 88 | export default MyComponent; 89 | ``` 90 | 91 | Register your component as a plugin, and wrap your application with the PluginStoreProvider 92 | 93 | ```tsx 94 | // src/index.tsx 95 | import { PluginStoreProvider } from 'react-plugins'; 96 | import ReactDOM from 'react-dom'; 97 | import App from './App'; 98 | import MyComponent from './MyComponent'; 99 | import store from './pluginStore'; 100 | 101 | store.registerPlugin('body', MyComponent, 'MyComponent', 10); 102 | 103 | ReactDOM.render( 104 | 105 | 106 | , 107 | document.getElementById('root') 108 | ); 109 | ``` 110 | 111 | Ask your app to render the plugins for the `body` section, with the `Plugins` component. 112 | 113 | ```tsx 114 | // src/index.tsx 115 | import { Plugins } from 'react-plugins'; 116 | 117 | const App = () => ( 118 |
119 |

120 | Below are the plugins registered in the body section of the 121 | PluginStore 122 |

123 | 124 |
125 | ); 126 | ``` 127 | 128 | ## Advanced usage 129 | 130 | ### Lazy-loading with Suspense 131 | 132 | Instead of importing your component and registering it as is, you can use `React.lazy` to import it dynamically. 133 | 134 | ```tsx 135 | // src/index.tsx 136 | import { lazy, Suspense } from 'react'; 137 | import { PluginStoreProvider } from 'react-plugins'; 138 | import ReactDOM from 'react-dom'; 139 | import App from './App'; 140 | import store from './pluginStore'; 141 | 142 | store.registerPlugin( 143 | 'body', 144 | lazy(() => import('./MyComponent')), 145 | 'MyComponent', 146 | 10 147 | ); 148 | 149 | ReactDOM.render( 150 | 151 | 152 | 153 | 154 | , 155 | document.getElementById('root') 156 | ); 157 | ``` 158 | 159 | ```tsx 160 | // src/index.tsx 161 | import { Plugins } from 'react-plugins'; 162 | 163 | const App = () => ( 164 |
165 |

166 | Below are the plugins registered in the body section of the 167 | PluginStore 168 |

169 | 170 |
171 | ); 172 | ``` 173 | 174 | ### Registering plugins with props 175 | 176 | react-plugins supports registering plugins that are components with props, simply by passing the JSX instead of the component declaration: 177 | 178 | ```tsx 179 | store.registerPlugin( 180 | 'body', 181 | , 182 | 'MyComponent', 183 | 10 184 | ); 185 | ``` 186 | 187 | ### Updating plugins 188 | 189 | When you register a plugin, you can update it whenever you want. This allows you for example to update it's props. Simply passe then new component to `registerPlugin` by specifying the same section and name as the previous one: 190 | 191 | ```tsx 192 | store.registerPlugin( 193 | 'body', 194 | , 195 | 'MyComponent', 196 | 10 197 | ); 198 | 199 | // later... 200 | 201 | store.registerPlugin( 202 | 'body', 203 | , 204 | 'MyComponent', 205 | 10 206 | ); 207 | ``` 208 | 209 | ### Registering plugins at runtime 210 | 211 | Plugins can be registered either before the initial rendering, or from inside another component. 212 | 213 | ```tsx 214 | const MyPluginHandler = () => { 215 | const [pluginProp, setPluginProp] = useState('value'); 216 | 217 | // register or update the plugin whenever pluginProp changes 218 | useEffect(() => { 219 | store.registerPlugin( 220 | 'body', 221 | , 222 | 'MyComponent', 223 | 10 224 | ); 225 | }, [pluginProp]); 226 | 227 | return (...); 228 | } 229 | 230 | // in another file 231 | 232 | 233 | const App = () => ; // will receive MyComponent and display it 234 | ``` 235 | 236 | ## API 237 | 238 | ### PluginStore 239 | 240 | **registerPlugin(section, component, name, priority)** 241 | 242 | Registers a plugin to be used in the application 243 | 244 | - section: The section this plugin will appear in 245 | - component: Either a component definition, or a component instance 246 | - name: a unique name to identify this component in the section it will appear in 247 | - priority: used to sort the plugins. A plugin with a higher priority will appear last. Lower priority comes first 248 | 249 | **removePlugin(section, name)** 250 | 251 | Removes a plugin from the given section, thus unmounting it if rendered anywhere 252 | 253 | - section: the section where the plugin is registered 254 | - name: the plugin to remove's name 255 | 256 | ### PluginStoreProvider 257 | 258 | Used to make your application aware of the plugins registered 259 | 260 | #### Props 261 | 262 | **store** (PluginStore) 263 | The store that will be used to retrieve plugins from 264 | 265 | ### Plugins 266 | 267 | Used to render plugins registered in a particular section 268 | 269 | #### Props 270 | 271 | **section** (string) 272 | The section to look for plugins. This component will render any plugin found in the given section, sorted by priority in ascending order 273 | 274 | ### usePlugins 275 | 276 | Retrieve all plugins ready to render. returns an array of JSX elements that can be mapped and rendered 277 | 278 | #### arguments 279 | 280 | **section** (string) 281 | The section to look for plugins. Will return all the components from this section, ready to render 282 | 283 | ## Roadmap 284 | 285 | - [x] Remove global PluginStore and make PluginStoreProvider accept a `pluginStore` as parameter 286 | - [x] `usePlugins` hook to retrieve plugins and leave rendering to the user 287 | 288 | ## License 289 | 290 | MIT © [lmerotta](https://github.com/lmerotta) 291 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | This example was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | It is linked to the react-plugins package in the parent directory for development purposes. 4 | 5 | You can run `npm install` and then `npm start` to test your package. 6 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-plugins-example", 3 | "homepage": ".", 4 | "version": "0.0.0", 5 | "private": true, 6 | "scripts": { 7 | "start": "node ../node_modules/react-scripts/bin/react-scripts.js start", 8 | "build": "node ../node_modules/react-scripts/bin/react-scripts.js build", 9 | "test": "node ../node_modules/react-scripts/bin/react-scripts.js test", 10 | "eject": "node ../node_modules/react-scripts/bin/react-scripts.js eject" 11 | }, 12 | "dependencies": { 13 | "@testing-library/jest-dom": "file:../node_modules/@testing-library/jest-dom", 14 | "@testing-library/react": "file:../node_modules/@testing-library/react", 15 | "@testing-library/user-event": "file:../node_modules/@testing-library/user-event", 16 | "@types/jest": "file:../node_modules/@types/jest", 17 | "@types/node": "file:../node_modules/@types/node", 18 | "@types/react": "file:../node_modules/@types/react", 19 | "@types/react-dom": "file:../node_modules/@types/react-dom", 20 | "react": "file:../node_modules/react", 21 | "react-dom": "file:../node_modules/react-dom", 22 | "react-scripts": "file:../node_modules/react-scripts", 23 | "typescript": "file:../node_modules/typescript", 24 | "react-plugins": "file:.." 25 | }, 26 | "devDependencies": { 27 | "@babel/plugin-syntax-object-rest-spread": "^7.8.3" 28 | }, 29 | "eslintConfig": { 30 | "extends": "react-app" 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmerotta-zz/react-plugins/c3b9e8b740a7726957d313c28e098f8a0c20c90b/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 16 | 17 | 18 | 27 | react-plugins 28 | 29 | 30 | 31 | 34 | 35 |
36 | 37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "react-plugins", 3 | "name": "react-plugins", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Sidebar from './components/Sidebar'; 3 | import ModulesPage from './components/Page'; 4 | import BillingSection from './modules/billing/components/BillingSection'; 5 | import AdminSection from './modules/admin/components/AdminSection'; 6 | 7 | const App = () => ( 8 |
16 | 17 | 18 | 19 | 20 | 21 |
22 | ); 23 | 24 | export default App; 25 | -------------------------------------------------------------------------------- /example/src/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Page: React.FC = ({ children }) => { 4 | return
{children}
; 5 | }; 6 | 7 | export default Page; 8 | -------------------------------------------------------------------------------- /example/src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Plugins } from 'react-plugins'; 3 | 4 | const Sidebar = () => ( 5 |
15 |

Logo

16 |
17 | 18 |
19 |
20 | ); 21 | 22 | export default Sidebar; 23 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | 3 | import React, { Suspense } from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import App from './App'; 6 | import store from './pluginStore'; 7 | import { PluginStoreProvider } from 'react-plugins'; 8 | 9 | ReactDOM.render( 10 | 11 | 12 | 13 | 14 | , 15 | document.getElementById('root') 16 | ); 17 | -------------------------------------------------------------------------------- /example/src/modules/admin/components/AdminSection.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import AdminWidget from './AdminWidget'; 3 | import PluginStore from '../../../pluginStore'; 4 | 5 | const AdminSection = () => { 6 | const [count, setCount] = useState(0); 7 | 8 | useEffect(() => { 9 | PluginStore.registerPlugin( 10 | 'sidebar', 11 | , 12 | 'admin-widget', 13 | -100 14 | ); 15 | }); 16 | 17 | return ( 18 |
19 |

Admin section

20 |

Set number of badges in the widget

21 | setCount(parseInt(e.target.value, 10) || 0)} 27 | /> 28 |
29 | ); 30 | }; 31 | 32 | export default AdminSection; 33 | -------------------------------------------------------------------------------- /example/src/modules/admin/components/AdminWidget.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type AdminWidgetProps = { 4 | badgeCount: number; 5 | }; 6 | 7 | const AdminWidget = ({ badgeCount }: AdminWidgetProps) => ( 8 |
9 | Admin 10 | 27 | {badgeCount > 99 ? '99+' : badgeCount} 28 | 29 |
30 | ); 31 | 32 | export default AdminWidget; 33 | -------------------------------------------------------------------------------- /example/src/modules/billing/components/BillingSection.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import PluginStore from '../../../pluginStore'; 3 | import BillingWidget from './BillingWidget'; 4 | 5 | const BillingSection = () => { 6 | const [isActive, setActive] = useState(true); 7 | 8 | useEffect(() => { 9 | if (isActive) { 10 | // will appear first 11 | PluginStore.registerPlugin('sidebar', BillingWidget, 'billing-widget', 0); 12 | } else { 13 | PluginStore.removePlugin('sidebar', 'billing-widget'); 14 | } 15 | }); 16 | 17 | return ( 18 |
19 |

Billing section

20 | 28 |
29 | ); 30 | }; 31 | 32 | export default BillingSection; 33 | -------------------------------------------------------------------------------- /example/src/modules/billing/components/BillingWidget.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const BillingWidget = () => ( 4 |
alert('Billing clicked')}> 5 | Billing 6 |
7 | ); 8 | 9 | export default BillingWidget; 10 | -------------------------------------------------------------------------------- /example/src/pluginStore.ts: -------------------------------------------------------------------------------- 1 | import { PluginStore } from 'react-plugins'; 2 | 3 | const store = new PluginStore(); 4 | 5 | export default store; 6 | -------------------------------------------------------------------------------- /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "esnext", 5 | "lib": [ 6 | "dom", 7 | "esnext" 8 | ], 9 | "moduleResolution": "node", 10 | "jsx": "react", 11 | "sourceMap": true, 12 | "declaration": true, 13 | "esModuleInterop": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "allowSyntheticDefaultImports": true, 22 | "target": "es5", 23 | "allowJs": true, 24 | "skipLibCheck": true, 25 | "strict": true, 26 | "forceConsistentCasingInFileNames": true, 27 | "resolveJsonModule": true, 28 | "isolatedModules": true, 29 | "noEmit": true 30 | }, 31 | "include": [ 32 | "src" 33 | ], 34 | "exclude": [ 35 | "node_modules", 36 | "build" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-plugins", 3 | "version": "0.3.1", 4 | "description": "Use your react component's as plugins!", 5 | "author": "lmerotta", 6 | "license": "MIT", 7 | "repository": "lmerotta/react-plugins", 8 | "main": "dist/index.js", 9 | "module": "dist/index.modern.js", 10 | "source": "src/index.tsx", 11 | "engines": { 12 | "node": ">=10" 13 | }, 14 | "scripts": { 15 | "build": "microbundle-crl --no-compress --format modern,cjs", 16 | "start": "microbundle-crl watch --no-compress --format modern,cjs", 17 | "prepare": "run-s build", 18 | "test": "run-s test:unit test:lint test:build", 19 | "test:build": "run-s build", 20 | "test:lint": "eslint .", 21 | "test:unit": "cross-env CI=1 react-scripts test --env=jsdom", 22 | "test:watch": "react-scripts test --env=jsdom", 23 | "predeploy": "cd example && npm install && npm run build", 24 | "deploy": "gh-pages -d example/build" 25 | }, 26 | "peerDependencies": { 27 | "react": "^16.8.0" 28 | }, 29 | "devDependencies": { 30 | "@testing-library/jest-dom": "^4.2.4", 31 | "@testing-library/react": "^9.5.0", 32 | "@testing-library/react-hooks": "^5.0.3", 33 | "@testing-library/user-event": "^7.2.1", 34 | "@types/jest": "^25.1.4", 35 | "@types/node": "^12.12.38", 36 | "@types/react": "^16.9.27", 37 | "@types/react-dom": "^16.9.7", 38 | "@typescript-eslint/eslint-plugin": "^2.34.0", 39 | "@typescript-eslint/parser": "^2.34.0", 40 | "babel-eslint": "^10.0.3", 41 | "cross-env": "^7.0.2", 42 | "eslint": "^6.8.0", 43 | "eslint-config-prettier": "^6.7.0", 44 | "eslint-config-standard": "^14.1.0", 45 | "eslint-config-standard-react": "^9.2.0", 46 | "eslint-plugin-import": "^2.18.2", 47 | "eslint-plugin-node": "^11.0.0", 48 | "eslint-plugin-prettier": "^3.1.1", 49 | "eslint-plugin-promise": "^4.2.1", 50 | "eslint-plugin-react": "^7.17.0", 51 | "eslint-plugin-standard": "^4.0.1", 52 | "gh-pages": "^2.2.0", 53 | "microbundle-crl": "^0.13.10", 54 | "npm-run-all": "^4.1.5", 55 | "prettier": "^2.0.4", 56 | "react": "^16.13.1", 57 | "react-dom": "^16.13.1", 58 | "react-scripts": "^3.4.1", 59 | "typescript": "^3.7.5" 60 | }, 61 | "files": [ 62 | "dist" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | }, 5 | "parser": "@typescript-eslint/parser", 6 | "plugins": ["@typescript-eslint"], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "rules": { 12 | "no-unused-vars": "off", 13 | "prettier/prettier": ["error", { "semi": true }] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/PluginStoreProvider.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import PluginStoreProvider from './PluginStoreProvider'; 4 | import PluginStore from '../utils/store'; 5 | 6 | describe('PluginStoreProvider', () => { 7 | it('Renders', () => { 8 | const { asFragment } = render( 9 | 10 | ); 11 | expect(asFragment()).toMatchSnapshot(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/PluginStoreProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import PluginStore from '../utils/store'; 3 | 4 | type ProviderContextType = { 5 | store: PluginStore; 6 | }; 7 | 8 | export const ProviderContext = React.createContext( 9 | {} as ProviderContextType 10 | ); 11 | 12 | type PluginStoreProviderProps = ProviderContextType; 13 | 14 | const PluginStoreProvider: React.FC = ({ 15 | children, 16 | store 17 | }) => { 18 | const contextValues = useMemo( 19 | () => ({ 20 | store 21 | }), 22 | [store] 23 | ); 24 | 25 | return ( 26 | 27 | {children} 28 | 29 | ); 30 | }; 31 | 32 | export default PluginStoreProvider; 33 | -------------------------------------------------------------------------------- /src/components/Plugins.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Plugins from './Plugins'; 3 | import PluginStoreProvider from './PluginStoreProvider'; 4 | import { act, render } from '@testing-library/react'; 5 | import PluginStore from '../utils/store'; 6 | 7 | describe('Plugins', () => { 8 | let store: PluginStore; 9 | beforeEach(() => { 10 | store = new PluginStore(); 11 | }); 12 | it('Renders the plugins from the given section', async () => { 13 | const component1 = () =>

I'm component 1

; 14 | const Component2 = () =>

I'm component 2

; 15 | 16 | store.registerPlugin('test', component1, 'first', 0); 17 | 18 | const { getByTestId } = render( 19 | 20 | 21 | 22 | ); 23 | 24 | expect(() => getByTestId('component1')).not.toThrow(); 25 | expect(() => getByTestId('component2')).toThrow(); 26 | 27 | act(() => { 28 | store.registerPlugin('test', , 'second', 1); 29 | }); 30 | 31 | expect(() => getByTestId('component1')).not.toThrow(); 32 | expect(() => getByTestId('component2')).not.toThrow(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/components/Plugins.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import usePlugins from '../hooks/usePlugins'; 3 | 4 | type PluginsProps = { 5 | section: string; 6 | }; 7 | 8 | const Plugins = ({ section }: PluginsProps): JSX.Element => { 9 | const plugins = usePlugins(section); 10 | 11 | return ( 12 | 13 | {plugins.map((component, index) => ( 14 | {component} 15 | ))} 16 | 17 | ); 18 | }; 19 | 20 | export default Plugins; 21 | -------------------------------------------------------------------------------- /src/components/__snapshots__/PluginStoreProvider.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`PluginStoreProvider Renders 1`] = ``; 4 | -------------------------------------------------------------------------------- /src/hooks/usePlugins.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import usePlugins from './usePlugins'; 3 | import PluginStoreProvider from '../components/PluginStoreProvider'; 4 | import { renderHook, act } from '@testing-library/react-hooks'; 5 | import PluginStore from '../utils/store'; 6 | 7 | describe('Plugins', () => { 8 | let store: PluginStore; 9 | 10 | const Component1 = () =>

I'm component 1

; 11 | const Component2 = () =>

I'm component 2

; 12 | const Component3 = () =>

I'm component 3

; 13 | 14 | beforeEach(() => { 15 | store = new PluginStore(); 16 | }); 17 | 18 | it('Renders the plugins from the given section', () => { 19 | store.registerPlugin('test', Component1, 'first', 0); 20 | const wrapper = ({ children }: any) => ( 21 | {children} 22 | ); 23 | 24 | const { result } = renderHook(() => usePlugins('test'), { wrapper }); 25 | 26 | expect(result.current.length).toEqual(1); 27 | expect(result.current[0]).toMatchInlineSnapshot(``); 28 | 29 | act(() => { 30 | store.registerPlugin('test', , 'second', 1); 31 | }); 32 | 33 | expect(result.current.length).toEqual(2); 34 | expect(result.current[1]).toMatchInlineSnapshot(``); 35 | 36 | act(() => { 37 | store.registerPlugin('test-other-section', , 'third', 1); 38 | }); 39 | 40 | expect(result.current.length).toEqual(2); 41 | expect(result.current[0]).toMatchInlineSnapshot(``); 42 | expect(result.current[1]).toMatchInlineSnapshot(``); 43 | }); 44 | 45 | it('Only subscribes to the store once', () => { 46 | jest.spyOn(store, 'subscribe'); 47 | 48 | expect(store.subscribe).toHaveBeenCalledTimes(0); 49 | 50 | store.registerPlugin('test', Component1, 'first', 0); 51 | 52 | renderHook(() => usePlugins('test'), { 53 | wrapper: ({ children }: any) => ( 54 | {children} 55 | ) 56 | }); 57 | 58 | expect(store.subscribe).toHaveBeenCalledTimes(1); 59 | 60 | act(() => { 61 | store.registerPlugin('test', , 'second', 1); 62 | }); 63 | 64 | expect(store.subscribe).toHaveBeenCalledTimes(1); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/hooks/usePlugins.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useMemo, useReducer } from 'react'; 2 | import { ProviderContext } from '../components/PluginStoreProvider'; 3 | 4 | const usePlugins = (section: string): Array => { 5 | const { store } = useContext(ProviderContext); 6 | const [, forceRender] = useReducer((s) => s + 1, 0); 7 | 8 | useEffect(() => { 9 | const unsub = store.subscribe(() => { 10 | forceRender(); 11 | }); 12 | 13 | return (): void => { 14 | unsub(); 15 | }; 16 | }, [store]); 17 | 18 | const plugins = store.getPluginsForSection(section); 19 | 20 | return useMemo( 21 | () => 22 | plugins.map((component) => { 23 | if (React.isValidElement(component)) { 24 | return (component as unknown) as JSX.Element; 25 | } else { 26 | const Component = component as React.ComponentType; 27 | return ; 28 | } 29 | }), 30 | [plugins] 31 | ); 32 | }; 33 | 34 | export default usePlugins; 35 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export { 3 | default as PluginStoreProvider, 4 | ProviderContext 5 | } from './components/PluginStoreProvider'; 6 | export { default as PluginStore } from './utils/store'; 7 | export { default as Plugins } from './components/Plugins'; 8 | export { default as usePlugins } from './hooks/usePlugins'; 9 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/types.tsx: -------------------------------------------------------------------------------- 1 | export type PluginType = { 2 | component: React.ComponentType | JSX.Element 3 | priority: number 4 | name?: string 5 | } 6 | 7 | export type PluginStoreType = { 8 | [key: string]: PluginType[] 9 | } 10 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Default CSS definition for typescript, 3 | * will be overridden with file-specific definitions by rollup 4 | */ 5 | declare module '*.css' { 6 | const content: { [className: string]: string }; 7 | export default content; 8 | } 9 | 10 | interface SvgrComponent extends React.StatelessComponent> {} 11 | 12 | declare module '*.svg' { 13 | const svgUrl: string; 14 | const svgComponent: SvgrComponent; 15 | export default svgUrl; 16 | export { svgComponent as ReactComponent } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/store.test.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PluginStore } from './store'; 3 | 4 | describe('PluginStore', () => { 5 | let store: PluginStore; 6 | 7 | beforeEach(() => { 8 | store = new PluginStore(); 9 | }); 10 | 11 | it('Calls all the subscribers with the whole list of sections when a plugin is registered', () => { 12 | const Plugin = jest.fn(); 13 | const subscriber1 = jest.fn(); 14 | const subscriber2 = jest.fn(); 15 | 16 | store.subscribe(subscriber1); 17 | const unsubscribe = store.subscribe(subscriber2); 18 | 19 | // Test with the two subscribers 20 | store.registerPlugin('test-section', Plugin, 'test-plugin'); 21 | 22 | expect(subscriber1).toHaveBeenCalledWith( 23 | expect.objectContaining({ 24 | 'test-section': expect.arrayContaining([ 25 | expect.objectContaining({ 26 | name: 'test-plugin' 27 | }) 28 | ]) 29 | }) 30 | ); 31 | 32 | expect(subscriber2).toHaveBeenCalledWith( 33 | expect.objectContaining({ 34 | 'test-section': expect.arrayContaining([ 35 | expect.objectContaining({ 36 | name: 'test-plugin' 37 | }) 38 | ]) 39 | }) 40 | ); 41 | 42 | // remove one subscriber and confirm that it is not called anymore 43 | unsubscribe(); 44 | 45 | store.registerPlugin('test-section', Plugin, 'test-plugin-2'); 46 | 47 | expect(subscriber1).toHaveBeenCalledWith( 48 | expect.objectContaining({ 49 | 'test-section': expect.arrayContaining([ 50 | expect.objectContaining({ 51 | name: 'test-plugin' 52 | }), 53 | expect.objectContaining({ 54 | name: 'test-plugin-2' 55 | }) 56 | ]) 57 | }) 58 | ); 59 | 60 | // should not have been called a second time 61 | expect(subscriber2).toHaveBeenCalledTimes(1); 62 | }); 63 | 64 | it('Replaces a component with a new instance', () => { 65 | const component1 = jest.fn(); 66 | const component2 = jest.fn(); 67 | const component3 = jest.fn(); 68 | 69 | store.registerPlugin('test-section', component1, 'test-plugin'); 70 | store.registerPlugin('test-section', component3, 'test-plugin-2'); 71 | store.registerPlugin('test-section', component2, 'test-plugin'); 72 | 73 | expect(store.getPluginsForSection('test-section')[0]).not.toEqual( 74 | component1 75 | ); 76 | expect(store.getPluginsForSection('test-section')[0]).toEqual(component2); 77 | expect(store.getPluginsForSection('test-section')[1]).toEqual(component3); 78 | }); 79 | 80 | it('Removes the plugin from the store and calls the subscribers', () => { 81 | const component = jest.fn(); 82 | const subscriber = jest.fn(); 83 | 84 | store.subscribe(subscriber); 85 | store.registerPlugin('test-section', component, 'test-plugin'); 86 | store.removePlugin('test-section', 'test-plugin'); 87 | 88 | expect(subscriber).toHaveBeenCalledWith({ 'test-section': [] }); 89 | expect(() => 90 | store.removePlugin('non-existent', 'non-existent') 91 | ).not.toThrow(); 92 | }); 93 | 94 | it('Returns the plugins for the given section, in a sorted order by priority', () => { 95 | const component1 = jest.fn(); 96 | const component2 = jest.fn(); 97 | const component3 = jest.fn(); 98 | 99 | store.registerPlugin('test-section', component3, '3', -100); 100 | store.registerPlugin('test-section', component1, '1', 100); 101 | store.registerPlugin('test-section', component2, '2', 0); 102 | 103 | expect(store.getPluginsForSection('test-section')).toEqual([ 104 | component3, 105 | component2, 106 | component1 107 | ]); 108 | expect(store.getPluginsForSection('non-existent')).toEqual([]); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/utils/store.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType } from 'react'; 2 | import { PluginStoreType } from '../types'; 3 | 4 | export class PluginStore { 5 | private sections: PluginStoreType = {}; 6 | private listeners: Array<(sections: PluginStoreType) => void> = []; 7 | 8 | subscribe(listener: (sections: PluginStoreType) => void): () => void { 9 | this.listeners.push(listener); 10 | 11 | // return unsubscribe callback 12 | return (): void => { 13 | this.listeners = this.listeners.filter((current) => current !== listener); 14 | }; 15 | } 16 | 17 | registerPlugin( 18 | section: string, 19 | component: ComponentType | JSX.Element, 20 | name: string, 21 | priority = 0 22 | ): void { 23 | const pluginStore = this.sections[section] || []; 24 | 25 | // if we find a component with the given name, replace it 26 | if (pluginStore.findIndex((current) => current.name === name) !== -1) { 27 | this.sections[section] = pluginStore.map((current) => 28 | current.name === name 29 | ? { 30 | component, 31 | priority, 32 | name 33 | } 34 | : current 35 | ); 36 | } else { 37 | this.sections[section] = [...pluginStore, { component, priority, name }]; 38 | } 39 | 40 | this.listeners.forEach((listener) => listener(this.sections)); 41 | } 42 | 43 | removePlugin(section: string, name: string): void { 44 | const pluginStore = this.sections[section] || []; 45 | 46 | this.sections[section] = pluginStore.filter( 47 | (current) => current.name !== name 48 | ); 49 | 50 | this.listeners.forEach((listener) => listener(this.sections)); 51 | } 52 | 53 | getPluginsForSection(section: string): Array { 54 | const pluginStore = this.sections[section]; 55 | 56 | if (!pluginStore || pluginStore.length < 1) { 57 | return []; 58 | } 59 | 60 | return pluginStore 61 | .sort((a, b) => a.priority - b.priority) 62 | .map((a) => a.component); 63 | } 64 | } 65 | 66 | export default PluginStore; 67 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "esnext", 5 | "lib": [ 6 | "dom", 7 | "esnext" 8 | ], 9 | "moduleResolution": "node", 10 | "jsx": "react", 11 | "sourceMap": true, 12 | "declaration": true, 13 | "esModuleInterop": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "allowSyntheticDefaultImports": true, 22 | "target": "es5", 23 | "allowJs": true, 24 | "skipLibCheck": true, 25 | "strict": true, 26 | "forceConsistentCasingInFileNames": true, 27 | "resolveJsonModule": true, 28 | "isolatedModules": true, 29 | "noEmit": true 30 | }, 31 | "include": [ 32 | "src" 33 | ], 34 | "exclude": [ 35 | "node_modules", 36 | "dist", 37 | "example" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } --------------------------------------------------------------------------------