From 0da688193d153f52c2dce9b6cd25120ef0bbb96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Maa=C3=9F?= <mathias.maass@uni-leipzig.de> Date: Thu, 14 Mar 2019 17:34:21 +0100 Subject: [PATCH] #2184 initial implementation of enhanced plugin pattern --- __tests__/src/extend/withPlugins.test.js | 107 ++++++++++++++ __tests__/src/lib/MiradorViewer.test.js | 80 ---------- __tests__/src/lib/miradorViewer.test.js | 181 +++++++++++++++++++++++ src/extend/index.js | 2 + src/extend/pluginStore.js | 11 ++ src/extend/withPlugins.js | 43 ++++++ src/index.js | 17 +-- src/init.js | 22 ++- src/lib/MiradorViewer.js | 109 -------------- src/lib/miradorViewer.js | 68 +++++++++ src/state/createStore.js | 8 +- 11 files changed, 439 insertions(+), 209 deletions(-) create mode 100644 __tests__/src/extend/withPlugins.test.js delete mode 100644 __tests__/src/lib/MiradorViewer.test.js create mode 100644 __tests__/src/lib/miradorViewer.test.js create mode 100644 src/extend/index.js create mode 100644 src/extend/pluginStore.js create mode 100644 src/extend/withPlugins.js delete mode 100644 src/lib/MiradorViewer.js create mode 100644 src/lib/miradorViewer.js diff --git a/__tests__/src/extend/withPlugins.test.js b/__tests__/src/extend/withPlugins.test.js new file mode 100644 index 000000000..31f4fede9 --- /dev/null +++ b/__tests__/src/extend/withPlugins.test.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 deleted file mode 100644 index 6aa52500b..000000000 --- a/__tests__/src/lib/MiradorViewer.test.js +++ /dev/null @@ -1,80 +0,0 @@ -import ReactDOM from 'react-dom'; -import MiradorViewer from '../../../src/lib/MiradorViewer'; - -jest.unmock('react-i18next'); - -describe('MiradorViewer', () => { - let instance; - beforeAll(() => { - ReactDOM.render = jest.fn(); - instance = new MiradorViewer({}); - }); - describe('constructor', () => { - it('returns viewer actions', () => { - expect(instance.actions.addWindow).toBeDefined(); - }); - it('returns viewer store', () => { - expect(instance.store.dispatch).toBeDefined(); - }); - it('renders via ReactDOM', () => { - expect(ReactDOM.render).toHaveBeenCalled(); - }); - }); - describe('processPlugins', () => { - it('combines actionCreators and reducers', () => { - const fooPlugin = { - actions: { - fooAction: () => {}, - }, - reducers: { - fooReducer: null, - }, - }; - window.Mirador = { - plugins: { - fooPlugin, - }, - }; - instance = new MiradorViewer({ - plugins: ['fooPlugin'], - }); - expect(instance.actions.fooAction).toBeDefined(); - expect(instance.store.pluginReducers).toBeDefined(); - }); - }); - describe('processConfig', () => { - it('transforms config values to actions to dispatch to store', () => { - instance = new MiradorViewer({ - id: 'mirador', - windows: [ - { - loadedManifest: 'https://iiif.harvardartmuseums.org/manifests/object/299843', - canvasIndex: 2, - }, - { - loadedManifest: 'https://iiif.harvardartmuseums.org/manifests/object/299843', - thumbnailNavigationPosition: 'off', - view: 'book', - }, - ], - manifests: { - 'http://media.nga.gov/public/manifests/nga_highlights.json': { provider: 'National Gallery of Art' }, - }, - }); - - const { windows, manifests } = instance.store.getState(); - const windowIds = Object.keys(windows); - expect(Object.keys(windowIds).length).toBe(2); - expect(windows[windowIds[0]].canvasIndex).toBe(2); - expect(windows[windowIds[1]].canvasIndex).toBe(0); - expect(windows[windowIds[0]].thumbnailNavigationPosition).toBe('bottom'); - expect(windows[windowIds[1]].thumbnailNavigationPosition).toBe('off'); - expect(windows[windowIds[0]].view).toBe('single'); - expect(windows[windowIds[1]].view).toBe('book'); - - const manifestIds = Object.keys(manifests); - expect(Object.keys(manifestIds).length).toBe(2); - expect(manifests['http://media.nga.gov/public/manifests/nga_highlights.json'].provider).toBe('National Gallery of Art'); - }); - }); -}); diff --git a/__tests__/src/lib/miradorViewer.test.js b/__tests__/src/lib/miradorViewer.test.js new file mode 100644 index 000000000..6a2033a6e --- /dev/null +++ b/__tests__/src/lib/miradorViewer.test.js @@ -0,0 +1,181 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import deepmerge from 'deepmerge'; +import miradorViewer from '../../../src/lib/miradorViewer'; + +jest.mock('react-dom', () => ({ + render: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + Provider: jest.fn(), +})); + +const config = { + id: 'mirador', + windows: [ + { + loadedManifest: 'https://iiif.harvardartmuseums.org/manifests/object/299843', + canvasIndex: 2, + }, + { + loadedManifest: 'https://iiif.bodleian.ox.ac.uk/iiif/manifest/e32a277e-91e2-4a6d-8ba6-cc4bad230410.json', + thumbnailNavigationPosition: 'off', + view: 'book', + }, + ], + manifests: { + 'https://media.nga.gov/public/manifests/nga_highlights.json': { provider: 'National Gallery of Art' }, + 'https://data.ucd.ie/api/img/manifests/ucdlib:33064': { provider: 'Irish Architectural Archive' }, + }, +}; + +const settings = { + window: { + defaultView: 'single', + }, + thumbnailNavigation: { + defaultPosition: 'bottom', + height: 150, + width: 100, + }, +}; + +/** */ const fooReducer = x => x; +/** */ const barReducer = x => x; +/** */ const bazReducer = x => x; + +const plugins = [ + { + reducers: { + foo: fooReducer, + bar: barReducer, + }, + }, + { + reducers: { + baz: bazReducer, + }, + }, +]; + +const pluginStore = { + storePlugins: jest.fn(), +}; + +const store = { + dispatch: jest.fn(), +}; + +const createStore = jest.fn().mockReturnValue(store); + +const actions = { + setConfig: jest.fn().mockReturnValue('RETVAL_SET_CONFIG'), + addWindow: jest.fn().mockReturnValue('RETVAL_ADD_WINDOW'), + fetchManifest: jest.fn().mockReturnValue('RETVAL_FETCH_MANIFEST'), + requestManifest: jest.fn().mockReturnValue('RETVAL_REQUEST_MANIFEST'), +}; + +/** */const App = props => null; + +/** +* Finally invoke function under test +*/ +const retval = miradorViewer({ + config, + settings, + plugins, + pluginStore, + createStore, + actions, + App, +}); + +it('should store plugins', () => { + expect(pluginStore.storePlugins).toBeCalledWith(plugins); +}); + +it('should create store and pass plugin reducers', () => { + const pluginReducers = { + foo: fooReducer, + bar: barReducer, + baz: bazReducer, + }; + expect(createStore).toBeCalledWith(pluginReducers); +}); + +it('should merge settings and config and write it to state', () => { + const merged = deepmerge(settings, config); + expect(actions.setConfig).toBeCalledWith(merged); + expect(store.dispatch).nthCalledWith(1, 'RETVAL_SET_CONFIG'); +}); + +it('should fetch manifest for each window in config', () => { + expect(actions.fetchManifest) + .toBeCalledWith(config.windows[0].loadedManifest); + expect(actions.fetchManifest) + .toBeCalledWith(config.windows[1].loadedManifest); + expect(store.dispatch) + .nthCalledWith(2, 'RETVAL_FETCH_MANIFEST'); + expect(store.dispatch) + .nthCalledWith(4, 'RETVAL_FETCH_MANIFEST'); +}); + +it('should create a window in state for each window in config', () => { + expect(actions.addWindow).toBeCalledTimes(2); + expect(store.dispatch).nthCalledWith(3, 'RETVAL_ADD_WINDOW'); + expect(store.dispatch).nthCalledWith(5, 'RETVAL_ADD_WINDOW'); +}); + +it('should set correct canvas index to windows in state', () => { + expect(actions.addWindow.mock.calls[0][0].canvasIndex).toBe(2); + expect(actions.addWindow.mock.calls[1][0].canvasIndex).toBe(0); +}); + +it('should set correct manifest id to windows in state', () => { + expect(actions.addWindow.mock.calls[0][0].manifestId) + .toBe(config.windows[0].loadedManifest); + expect(actions.addWindow.mock.calls[1][0].manifestId) + .toBe(config.windows[1].loadedManifest); +}); + +it('should set correct thumbnail posistion to windows in state', () => { + expect(actions.addWindow.mock.calls[0][0].thumbnailNavigationPosition) + .toBe('bottom'); + expect(actions.addWindow.mock.calls[1][0].thumbnailNavigationPosition) + .toBe('off'); +}); + +it('should set correct view type to windows in state', () => { + expect(actions.addWindow.mock.calls[0][0].view).toBe('single'); + expect(actions.addWindow.mock.calls[1][0].view).toBe('book'); +}); + +it('should "request manifest" for each manifest in config', () => { + expect(actions.requestManifest).nthCalledWith( + 1, + 'https://media.nga.gov/public/manifests/nga_highlights.json', + { provider: 'National Gallery of Art' }, + ); + expect(actions.requestManifest).nthCalledWith( + 2, + 'https://data.ucd.ie/api/img/manifests/ucdlib:33064', + { provider: 'Irish Architectural Archive' }, + ); + expect(store.dispatch).nthCalledWith(6, 'RETVAL_REQUEST_MANIFEST'); + expect(store.dispatch).nthCalledWith(7, 'RETVAL_REQUEST_MANIFEST'); +}); + +it('should render app an provide it with the store', () => { + const AppWithStore = ( + <Provider store={store}> + <App /> + </Provider> + ); + expect(ReactDOM.render.mock.calls[0][0]).toEqual(AppWithStore); +}); + +it('should return the store', () => { + expect(retval).toEqual({ store, actions }); +}); diff --git a/src/extend/index.js b/src/extend/index.js new file mode 100644 index 000000000..661d66ff6 --- /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 000000000..56d6182fc --- /dev/null +++ b/src/extend/pluginStore.js @@ -0,0 +1,11 @@ + +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 000000000..681eca0a8 --- /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 89fcbb163..56cf22882 100644 --- a/src/index.js +++ b/src/index.js @@ -1,12 +1,9 @@ -import init from './init'; +import { initViewer } 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: {}, +export default { + viewer: initViewer, + actions, + selectors, }; - -export default exports; diff --git a/src/init.js b/src/init.js index eac97062d..d9c9bde7e 100644 --- a/src/init.js +++ b/src/init.js @@ -1,9 +1,21 @@ -import MiradorViewer from './lib/MiradorViewer'; +import miradorViewer from './lib/miradorViewer'; +import settings from './config/settings'; +import { pluginStore } from './extend'; +import createStore from './state/createStore'; +import * as actions from './state/actions'; +import App from './containers/App'; import './styles/index.scss'; - /** - * Default Mirador instantiation + * Init mirador viewer */ -export default function (config) { - return new MiradorViewer(config); +export function initViewer(config, plugins) { + return miradorViewer({ + config, + settings, + plugins, + pluginStore, + createStore, + actions, + App, + }); } diff --git a/src/lib/MiradorViewer.js b/src/lib/MiradorViewer.js deleted file mode 100644 index 30e9b27f9..000000000 --- a/src/lib/MiradorViewer.js +++ /dev/null @@ -1,109 +0,0 @@ -import React from 'react'; -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 createStore from '../state/createStore'; -import * as actions from '../state/actions'; -import settings from '../config/settings'; - -/** - * Default Mirador instantiation - */ -class MiradorViewer { - /** - */ - constructor(config) { - this.store = createStore(); - this.config = config; - this.processPlugins(); - this.processConfig(); - const viewer = { - actions, - store: this.store, - }; - - ReactDOM.render( - <Provider store={this.store}> - <App /> - </Provider>, - document.getElementById(config.id), - ); - - return viewer; - } - - /** - * Process config into actions - */ - processConfig() { - const mergedConfig = deepmerge(settings, this.config); - const action = actions.setConfig(mergedConfig); - this.store.dispatch(action); - - mergedConfig.windows.forEach((miradorWindow) => { - let thumbnailNavigationPosition; - let view; - if (miradorWindow.thumbnailNavigationPosition !== undefined) { - ({ thumbnailNavigationPosition } = miradorWindow); - } else { - thumbnailNavigationPosition = mergedConfig.thumbnailNavigation.defaultPosition; - } - if (miradorWindow.view !== undefined) { - ({ view } = miradorWindow); - } else { - view = mergedConfig.window.defaultView; - } - this.store.dispatch(actions.fetchManifest(miradorWindow.loadedManifest)); - this.store.dispatch(actions.addWindow({ - canvasIndex: (miradorWindow.canvasIndex || 0), - manifestId: miradorWindow.loadedManifest, - view, - thumbnailNavigationPosition, - })); - }); - - Object.keys(mergedConfig.manifests || {}).forEach((manifestId) => { - this.store.dispatch( - actions.requestManifest(manifestId, mergedConfig.manifests[manifestId]), - ); - }); - } - - /** - * 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)); - } -} - -export default MiradorViewer; diff --git a/src/lib/miradorViewer.js b/src/lib/miradorViewer.js new file mode 100644 index 000000000..62747d2a8 --- /dev/null +++ b/src/lib/miradorViewer.js @@ -0,0 +1,68 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import deepmerge from 'deepmerge'; + +/** Default Mirador instantiation */ +export default function ({ + config, + settings, + plugins, + pluginStore, + createStore, + actions, + App, +}) { + pluginStore.storePlugins(plugins); + const store = createStore(getReducersFromPlugins(plugins)); + processConfig(store, actions, config, settings); + + ReactDOM.render( + <Provider store={store}> + <App /> + </Provider>, + document.getElementById(config.id), + ); + + return { store, actions }; +} + +/** Return reducers from plugins */ +function getReducersFromPlugins(plugins) { + return plugins && plugins.reduce((acc, plugin) => ({ ...acc, ...plugin.reducers }), {}); +} + +/** Process config */ +function processConfig(store, actions, config, settings) { // eslint-disable-line no-shadow + const mergedConfig = writeConfigToState(store, actions, config, settings); + processWindowsFromConfig(store, actions, mergedConfig); + processManifestsFromConfig(store, actions, mergedConfig); +} + +/** Write config to state */ +function writeConfigToState(store, actions, config, settings) { // eslint-disable-line no-shadow + const mergedConfig = deepmerge(settings, config); + store.dispatch(actions.setConfig(mergedConfig)); + return mergedConfig; +} + +/** Process windows from config */ +function processWindowsFromConfig(store, actions, config) { // eslint-disable-line no-shadow + config.windows.forEach((win) => { + store.dispatch(actions.fetchManifest(win.loadedManifest)); + store.dispatch(actions.addWindow({ + canvasIndex: win.canvasIndex || 0, + manifestId: win.loadedManifest, + thumbnailNavigationPosition: + win.thumbnailNavigationPosition || config.thumbnailNavigation.defaultPosition, + view: win.view || config.window.defaultView, + })); + }); +} + +/** Process manifests from config */ +function processManifestsFromConfig(store, actions, config) { // eslint-disable-line no-shadow + Object.keys(config.manifests || {}).forEach((manifestId) => { + store.dispatch(actions.requestManifest(manifestId, config.manifests[manifestId])); + }); +} diff --git a/src/state/createStore.js b/src/state/createStore.js index bf052d19b..9a30f6ab3 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; } -- GitLab