diff --git a/__tests__/src/extend/withPlugins.js b/__tests__/src/extend/withPlugins.js new file mode 100644 index 0000000000000000000000000000000000000000..31f4fede990ec7f72abbefee0de630095fd69f3c --- /dev/null +++ b/__tests__/src/extend/withPlugins.js @@ -0,0 +1,107 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { withPlugins } from '../../../src/extend'; +import { pluginStore } from '../../../src/extend/pluginStore'; + + +jest.mock('../../../src/extend/pluginStore'); + +/** */ +const Target = props => <div>Hello</div>; + +/** create wrapper */ +function createWrapper(plugins) { + pluginStore.getPlugins = () => plugins; + const props = { foo: 1, bar: 2 }; + const PluginWrapper = withPlugins('Target', Target); + return shallow(<PluginWrapper {...props} />); +} + +describe('withPlugins', () => { + it('should return a function (normal function call)', () => { + expect(withPlugins('Target', Target)).toBeInstanceOf(Function); + }); + + it('should return a function (curry function call)', () => { + expect(withPlugins('Target')(Target)).toBeInstanceOf(Function); + }); + + it('displayName prop of returned function is based on target name argument', () => { + expect(withPlugins('Bubu', Target).displayName) + .toBe('WithPlugins(Bubu)'); + }); +}); + +describe('PluginHoc: if no plugin exists for the target', () => { + it('renders the target component', () => { + const wrapper = createWrapper([]); + expect(wrapper.find(Target).length).toBe(1); + expect(wrapper.find(Target).props().foo).toBe(1); + expect(wrapper.find(Target).props().bar).toBe(2); + }); +}); + +describe('PluginHoc: if a delete plugin exists for the target', () => { + it('renders nothing', () => { + const plugin = { + target: 'Target', + mode: 'delete', + }; + const wrapper = createWrapper([plugin]); + expect(wrapper.find('*').length).toBe(0); + }); +}); + +describe('PluginHoc: if a replace plugin exists for the target', () => { + it('renders the plugin component', () => { + /** */ + const PluginComponent = props => <div>look i am a plugin</div>; + const plugin = { + target: 'Target', + mode: 'replace', + component: PluginComponent, + }; + const wrapper = createWrapper([plugin]); + const selector = 'Connect(PluginComponent)'; + expect(wrapper.find(selector).length).toBe(1); + expect(wrapper.find(selector).props().foo).toBe(1); + expect(wrapper.find(selector).props().bar).toBe(2); + }); +}); + +describe('PluginHoc: if a add plugin exists for the target', () => { + it('renders the target component and passes the plugin component as a prop', () => { + /** */ + const PluginComponent = props => <div>look i am a plugin</div>; + const plugin = { + target: 'Target', + mode: 'add', + component: PluginComponent, + }; + const wrapper = createWrapper([plugin]); + expect(wrapper.find(Target).length).toBe(1); + expect(wrapper.find(Target).props().foo).toBe(1); + expect(wrapper.find(Target).props().bar).toBe(2); + expect(wrapper.find(Target).props().PluginComponent.WrappedComponent) + .toBe(PluginComponent); + }); +}); + +describe('PluginHoc: if a wrap plugin extists for the target', () => { + it('renders the plugin component and passes the target component as a prop', () => { + /** */ + const PluginComponent = props => <div>look i am a plugin</div>; + const plugin = { + target: 'Target', + mode: 'wrap', + component: PluginComponent, + }; + const wrapper = createWrapper([plugin]); + const selector = 'Connect(PluginComponent)'; + expect(wrapper.find(selector).length).toBe(1); + expect(wrapper.find(selector).props().foo).toBe(1); + expect(wrapper.find(selector).props().bar).toBe(2); + expect(wrapper.find(selector).props().TargetComponent) + .toBe(Target); + }); +}); diff --git a/__tests__/src/lib/MiradorViewer.test.js b/__tests__/src/lib/MiradorViewer.test.js index 90b81d3306c1f979cd87051d6d198d157248577d..5eb139764d36d3e5ca43cb6f3ba0e7063806461b 100644 --- a/__tests__/src/lib/MiradorViewer.test.js +++ b/__tests__/src/lib/MiradorViewer.test.js @@ -1,7 +1,10 @@ import ReactDOM from 'react-dom'; +import { pluginStore } from '../../../src/extend'; import MiradorViewer from '../../../src/lib/MiradorViewer'; jest.unmock('react-i18next'); +jest.mock('../../../src/extend'); +jest.mock('react-dom'); describe('MiradorViewer', () => { let instance; @@ -20,26 +23,29 @@ describe('MiradorViewer', () => { expect(ReactDOM.render).toHaveBeenCalled(); }); }); - describe('processPlugins', () => { - it('combines actionCreators and reducers', () => { - const fooPlugin = { - actions: { - fooAction: () => {}, - }, - reducers: { - fooReducer: null, + describe('process plugins', () => { + it('should store plugins and set reducers to state', () => { + /** */ const fooReducer = (state = 0) => state; + /** */ const barReducer = (state = 0) => state; + /** */ const bazReducer = (state = 0) => state; + /** */ const plugins = [ + { + reducers: { + foo: fooReducer, + bar: barReducer, + }, }, - }; - window.Mirador = { - plugins: { - fooPlugin, + { + reducers: { + baz: bazReducer, + }, }, - }; - instance = new MiradorViewer({ - plugins: ['fooPlugin'], - }); - expect(instance.actions.fooAction).toBeDefined(); - expect(instance.store.pluginReducers).toBeDefined(); + ]; + instance = new MiradorViewer({}, plugins); + expect(pluginStore.storePlugins).toBeCalledWith(plugins); + expect(instance.store.getState().foo).toBeDefined(); + expect(instance.store.getState().bar).toBeDefined(); + expect(instance.store.getState().baz).toBeDefined(); }); }); describe('processConfig', () => { diff --git a/package.json b/package.json index f0a6eec10933b8b789d5efccf052bb3537069d40..9d8d741a6dbb7738525566873554ad5b57e5884c 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "repository": "https://github.com/ProjectMirador/mirador", "size-limit": [ { - "limit": "350 KB", + "limit": "360 KB", "path": "dist/mirador.min.js" } ], diff --git a/src/extend/index.js b/src/extend/index.js new file mode 100644 index 0000000000000000000000000000000000000000..661d66ff66f8264b9fa5fe425ebe6cb277a67be1 --- /dev/null +++ b/src/extend/index.js @@ -0,0 +1,2 @@ +export * from './pluginStore'; +export * from './withPlugins'; diff --git a/src/extend/pluginStore.js b/src/extend/pluginStore.js new file mode 100644 index 0000000000000000000000000000000000000000..19084ac8ed20cbb9efe0eb4147706a6a9b27b210 --- /dev/null +++ b/src/extend/pluginStore.js @@ -0,0 +1,10 @@ +export const pluginStore = { + /** */ + storePlugins(plugins) { + this.plugins = plugins || []; + }, + /** */ + getPlugins() { + return this.plugins || []; + }, +}; diff --git a/src/extend/withPlugins.js b/src/extend/withPlugins.js new file mode 100644 index 0000000000000000000000000000000000000000..681eca0a8d65ce719e5b1529ea81ede3578d953e --- /dev/null +++ b/src/extend/withPlugins.js @@ -0,0 +1,43 @@ +import React, { Component } from 'react'; +import curry from 'lodash/curry'; +import { connect } from 'react-redux'; +import { pluginStore } from '.'; + +/** withPlugins should be the innermost HOC */ +function _withPlugins(targetName, TargetComponent) { // eslint-disable-line no-underscore-dangle + /** plugin wrapper hoc */ + class PluginHoc extends Component { + /** render */ + render() { + const plugin = pluginStore.getPlugins().find(p => p.target === targetName); + + if (plugin && plugin.mode === 'delete') { + return null; + } + if (plugin && plugin.mode === 'replace') { + const PluginComponent = connectPluginComponent(plugin); + return <PluginComponent {...this.props} />; + } + if (plugin && plugin.mode === 'add') { + const PluginComponent = connectPluginComponent(plugin); + return <TargetComponent {...this.props} PluginComponent={PluginComponent} />; + } + if (plugin && plugin.mode === 'wrap') { + const PluginComponent = connectPluginComponent(plugin); + return <PluginComponent {...this.props} TargetComponent={TargetComponent} />; + } + return <TargetComponent {...this.props} />; + } + } + + PluginHoc.displayName = `WithPlugins(${targetName})`; + return PluginHoc; +} + +/** Connect plugin component to state */ +function connectPluginComponent(plugin) { + return connect(plugin.mapStateToProps, plugin.mapDispatchToProps)(plugin.component); +} + +/** withPlugins('MyComponent')(MyComponent) */ +export const withPlugins = curry(_withPlugins); diff --git a/src/index.js b/src/index.js index 89fcbb163453368df0375030687dd22d8c9846e3..660bc6849bf716a21fe2c8ee274ee37ad25cb6d3 100644 --- a/src/index.js +++ b/src/index.js @@ -1,12 +1,14 @@ import init from './init'; +import * as actions from './state/actions'; +import * as selectors from './state/selectors'; export * from './components'; -export * from './state/actions'; export * from './state/reducers'; const exports = { viewer: init, - plugins: {}, + actions, + selectors, }; export default exports; diff --git a/src/init.js b/src/init.js index 30ccb0096723584417f8bf53fec673e1e2cb9869..0f3f58b73db07565bd79be0d6dd4e83e8490fb91 100644 --- a/src/init.js +++ b/src/init.js @@ -3,6 +3,6 @@ import MiradorViewer from './lib/MiradorViewer'; /** * Default Mirador instantiation */ -export default function (config) { - return new MiradorViewer(config); +export default function (config, plugins) { + return new MiradorViewer(config, plugins); } diff --git a/src/lib/MiradorViewer.js b/src/lib/MiradorViewer.js index 30e9b27f9dea2bf1a04bb9972f189068f372f877..e9cf6296f624e6dc55e5adbdb2c640a36ed9774b 100644 --- a/src/lib/MiradorViewer.js +++ b/src/lib/MiradorViewer.js @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import deepmerge from 'deepmerge'; import App from '../containers/App'; -import createRootReducer from '../state/reducers/rootReducer'; +import { pluginStore } from '../extend'; import createStore from '../state/createStore'; import * as actions from '../state/actions'; import settings from '../config/settings'; @@ -14,10 +14,11 @@ import settings from '../config/settings'; class MiradorViewer { /** */ - constructor(config) { - this.store = createStore(); + constructor(config, plugins) { + pluginStore.storePlugins(plugins); + const pluginReducers = getReducersFromPlugins(plugins); + this.store = createStore(pluginReducers); this.config = config; - this.processPlugins(); this.processConfig(); const viewer = { actions, @@ -70,40 +71,11 @@ class MiradorViewer { ); }); } +} - /** - * Process Plugins - */ - processPlugins() { - const plugins = this.config.plugins || []; - const actionCreators = []; - const reducers = []; - - plugins.forEach((pluginName) => { - const plugin = window.Mirador.plugins[pluginName]; - - // Add Actions - if (plugin.actions) { - Object.keys(plugin.actions) - .forEach(actionName => actionCreators.push({ - name: actionName, - action: plugin.actions[actionName], - })); - } - // Add Reducers - if (plugin.reducers) { - Object.keys(plugin.reducers) - .forEach(reducerName => reducers.push({ - name: reducerName, - reducer: plugin.reducers[reducerName], - })); - } - }); - - actionCreators.forEach((action) => { actions[action.name] = action.action; }); - reducers.forEach((reducer) => { this.store.pluginReducers[reducer.name] = reducer.reducer; }); - this.store.replaceReducer(createRootReducer(this.store.pluginReducers)); - } +/** Return reducers from plugins */ +function getReducersFromPlugins(plugins) { + return plugins && plugins.reduce((acc, plugin) => ({ ...acc, ...plugin.reducers }), {}); } export default MiradorViewer; diff --git a/src/state/createStore.js b/src/state/createStore.js index bf052d19b04c55dd8bc4e3219647582085f84bdb..9a30f6ab33710de5517aad1601aff11b489eafa2 100644 --- a/src/state/createStore.js +++ b/src/state/createStore.js @@ -13,9 +13,9 @@ import createRootReducer from './reducers/rootReducer'; /** * Configure Store */ -export default function () { - const store = createStore( - createRootReducer(), +export default function (pluginReducers) { + return createStore( + createRootReducer(pluginReducers), composeWithDevTools( applyMiddleware( createDebounce(), @@ -23,6 +23,4 @@ export default function () { ), ), ); - store.pluginReducers = {}; - return store; }