├── .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 | [](https://www.npmjs.com/package/react-plugins) [](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 |
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 | }
--------------------------------------------------------------------------------