From fd0d0c8d29cc97d65f46e922da0862a10da39f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Maa=C3=9F?= <mathias.maass@uni-leipzig.de> Date: Tue, 19 Mar 2019 16:07:32 +0100 Subject: [PATCH] Mechanism for negotiating the hierarchy of plugins. Closes #2188 #2185 --- __tests__/src/extend/pluginStore.test.js | 78 +++++++++ __tests__/src/extend/withPlugins.test.js | 164 ++++++++++++++++++ .../WorkspaceControlPanelButtons.js | 9 +- src/extend/pluginStore.js | 56 +++++- src/extend/withPlugins.js | 46 +++-- 5 files changed, 333 insertions(+), 20 deletions(-) create mode 100644 __tests__/src/extend/pluginStore.test.js create mode 100644 __tests__/src/extend/withPlugins.test.js diff --git a/__tests__/src/extend/pluginStore.test.js b/__tests__/src/extend/pluginStore.test.js new file mode 100644 index 000000000..7afea0b72 --- /dev/null +++ b/__tests__/src/extend/pluginStore.test.js @@ -0,0 +1,78 @@ +import { pluginStore } from '../../../src/extend'; + +describe('storePlugins()', () => { + it('should run without throw error when Array is passed', () => { + expect(() => pluginStore.storePlugins([])).not.toThrow(); + }); + it('should run without throw error when nothing is passed', () => { + expect(() => pluginStore.storePlugins()).not.toThrow(); + }); +}); + +describe('getPlugins', () => { + it('with no plugins should return undefined', () => { + pluginStore.storePlugins(); + expect(pluginStore.getPlugins('target')).not.toBeDefined(); + }); + it('returns { mode -> plugin } mapping for target', () => { + const plugins = [ + { target: 'Window', mode: 'delete' }, + { target: 'Window', mode: 'delete' }, + { target: 'Window', mode: 'replace' }, + { target: 'Window', mode: 'replace' }, + { target: 'Window', mode: 'wrap' }, + { target: 'Window', mode: 'wrap' }, + { target: 'Window', mode: 'add' }, + { target: 'Window', mode: 'add' }, + + { target: 'TopBar', mode: 'delete' }, + { target: 'TopBar', mode: 'delete' }, + { target: 'TopBar', mode: 'replace' }, + { target: 'TopBar', mode: 'replace' }, + { target: 'TopBar', mode: 'wrap' }, + { target: 'TopBar', mode: 'wrap' }, + { target: 'TopBar', mode: 'add' }, + { target: 'TopBar', mode: 'add' }, + ]; + + pluginStore.storePlugins(plugins); + + expect(pluginStore.getPlugins('Window')).toEqual({ + delete: [ + { target: 'Window', mode: 'delete' }, + { target: 'Window', mode: 'delete' }, + ], + replace: [ + { target: 'Window', mode: 'replace' }, + { target: 'Window', mode: 'replace' }, + ], + wrap: [ + { target: 'Window', mode: 'wrap' }, + { target: 'Window', mode: 'wrap' }, + ], + add: [ + { target: 'Window', mode: 'add' }, + { target: 'Window', mode: 'add' }, + ], + }); + + expect(pluginStore.getPlugins('TopBar')).toEqual({ + delete: [ + { target: 'TopBar', mode: 'delete' }, + { target: 'TopBar', mode: 'delete' }, + ], + replace: [ + { target: 'TopBar', mode: 'replace' }, + { target: 'TopBar', mode: 'replace' }, + ], + wrap: [ + { target: 'TopBar', mode: 'wrap' }, + { target: 'TopBar', mode: 'wrap' }, + ], + add: [ + { target: 'TopBar', mode: 'add' }, + { target: 'TopBar', mode: 'add' }, + ], + }); + }); +}); diff --git a/__tests__/src/extend/withPlugins.test.js b/__tests__/src/extend/withPlugins.test.js new file mode 100644 index 000000000..11509fedd --- /dev/null +++ b/__tests__/src/extend/withPlugins.test.js @@ -0,0 +1,164 @@ +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 createPluginHoc(plugins) { + pluginStore.getPlugins = () => plugins; + const props = { foo: 1, bar: 2 }; + const PluginHoc = withPlugins('Target', Target); + return shallow(<PluginHoc {...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 hoc = createPluginHoc([]); + expect(hoc.find(Target).length).toBe(1); + expect(hoc.find(Target).props().foo).toBe(1); + expect(hoc.find(Target).props().bar).toBe(2); + }); +}); + +describe('PluginHoc: if a delete-plugin exists for the target', () => { + it('renders nothing', () => { + const plugins = { + delete: [ + { target: 'Target', mode: 'delete' }, + ], + replace: [ + { target: 'Target', mode: 'replace' }, + ], + wrap: [ + { target: 'Target', mode: 'wrap' }, + ], + add: [ + { target: 'Target', mode: 'add' }, + ], + }; + + const hoc = createPluginHoc(plugins); + expect(hoc.find('*').length).toBe(0); + }); +}); + +describe('PluginHoc: if replace-plugins exists but no delete-plugin', () => { + it('renders the first replace-plugin component', () => { + /** */ const ReplacePluginComponentA = props => <div>look i am a plugin</div>; + /** */ const ReplacePluginComponentB = props => <div>look i am a plugin</div>; + /** */ const WrapPluginComponent = props => <div>look i am a plugin</div>; + /** */ const AddPluginComponent = props => <div>look i am a plugin</div>; + const plugins = { + replace: [ + { target: 'Target', mode: 'replace', component: ReplacePluginComponentA }, + { target: 'Target', mode: 'replace', component: ReplacePluginComponentB }, + ], + wrap: [ + { target: 'Target', mode: 'wrap', component: WrapPluginComponent }, + ], + add: [ + { target: 'Target', mode: 'add', component: AddPluginComponent }, + ], + }; + const hoc = createPluginHoc(plugins); + const selector = 'Connect(ReplacePluginComponentA)'; + expect(hoc.find(selector).length).toBe(1); + expect(hoc.find(selector).props().foo).toBe(1); + expect(hoc.find(selector).props().bar).toBe(2); + }); +}); + +describe('PluginHoc: if wrap-plugins exists but no delete-plugin, no replace-plugin and no add-plugins', () => { + it('renders the first wrap-plugin and passes the target component as prop', () => { + /** */ const WrapPluginComponentA = props => <div>look i am a plugin</div>; + /** */ const WrapPluginComponentB = props => <div>look i am a plugin</div>; + const plugins = { + wrap: [ + { target: 'Target', mode: 'wrap', component: WrapPluginComponentA }, + { target: 'Target', mode: 'wrap', component: WrapPluginComponentB }, + ], + }; + const hoc = createPluginHoc(plugins); + const selector = 'Connect(WrapPluginComponentA)'; + expect(hoc.find(selector).length).toBe(1); + expect(hoc.find(selector).props().foo).toBe(1); + expect(hoc.find(selector).props().bar).toBe(2); + expect(hoc.find(selector).props().TargetComponent).toBe(Target); + }); +}); + +describe('PluginHoc: if add-plugins exist but no delete-plugin, no replace-plugin and no wrap-plugin', () => { + it('renders the target component and passes all add-plugin components as a prop', () => { + /** */ const AddPluginComponentA = props => <div>look i am a plugin</div>; + /** */ const AddPluginComponentB = props => <div>look i am a plugin</div>; + const plugins = { + add: [ + { target: 'Target', mode: 'add', component: AddPluginComponentA }, + { target: 'Target', mode: 'add', component: AddPluginComponentB }, + ], + }; + const hoc = createPluginHoc(plugins); + const selector = Target; + expect(hoc.find(selector).length).toBe(1); + expect(hoc.find(selector).props().foo).toBe(1); + expect(hoc.find(selector).props().bar).toBe(2); + expect(hoc.find(selector).props().PluginComponents[0].displayName) + .toBe('Connect(AddPluginComponentA)'); + expect(hoc.find(selector).props().PluginComponents[1].displayName) + .toBe('Connect(AddPluginComponentB)'); + }); +}); + +describe('PluginHoc: if add-plugins AND wrap-plugins exist but no delete-plugin and no replace-plugin', () => { + it('passes all add-plugin components to target component and renders the first wrap-plugin with the target component as prop', () => { + /** */ const WrapPluginComponentA = props => <div>look i am a plugin</div>; + /** */ const WrapPluginComponentB = props => <div>look i am a plugin</div>; + /** */ const AddPluginComponentA = props => <div>look i am a plugin</div>; + /** */ const AddPluginComponentB = props => <div>look i am a plugin</div>; + const plugins = { + add: [ + { target: 'Target', mode: 'add', component: AddPluginComponentA }, + { target: 'Target', mode: 'add', component: AddPluginComponentB }, + ], + wrap: [ + { target: 'Target', mode: 'wrap', component: WrapPluginComponentA }, + { target: 'Target', mode: 'wrap', component: WrapPluginComponentB }, + ], + }; + const hoc = createPluginHoc(plugins); + const selector = 'Connect(WrapPluginComponentA)'; + expect(hoc.find(selector).length).toBe(1); + expect(hoc.find(selector).props().foo).toBe(1); + expect(hoc.find(selector).props().bar).toBe(2); + + const { TargetComponent } = hoc.find(selector).props(); + const target = shallow(<TargetComponent bubu={10} kiki={20} />); + expect(target.props().bubu).toBe(10); + expect(target.props().kiki).toBe(20); + expect(target.props().PluginComponents[0].displayName) + .toBe('Connect(AddPluginComponentA)'); + expect(target.props().PluginComponents[1].displayName) + .toBe('Connect(AddPluginComponentB)'); + }); +}); diff --git a/src/components/WorkspaceControlPanelButtons.js b/src/components/WorkspaceControlPanelButtons.js index 3e23a20ed..b4150e929 100644 --- a/src/components/WorkspaceControlPanelButtons.js +++ b/src/components/WorkspaceControlPanelButtons.js @@ -5,8 +5,13 @@ import WorkspaceMenuButton from '../containers/WorkspaceMenuButton'; /** Renders plugins */ const PluginHook = (props) => { - const { PluginComponent } = props; // eslint-disable-line react/prop-types - return PluginComponent ? <PluginComponent {...props} /> : null; + const { PluginComponents } = props; // eslint-disable-line react/prop-types + if (PluginComponents) { + return PluginComponents.map((PluginComponent, index) => ( + <PluginComponent {...props} key={index} /> // eslint-disable-line react/no-array-index-key + )); + } + return null; }; /** diff --git a/src/extend/pluginStore.js b/src/extend/pluginStore.js index 0aee6f1cf..a95b8c91c 100644 --- a/src/extend/pluginStore.js +++ b/src/extend/pluginStore.js @@ -1,10 +1,56 @@ +import update from 'lodash/update'; + export const pluginStore = { - /** */ - getPlugins() { - return this.plugins || []; + /** + * Get plugins for target + * + * @param {String} targetName + * @return {Object | undefined } - looks like: + * + * { + * delete: [plugin1, ...], + * replace: [plugin2, ...], + * wrap: [plugin3, ...], + * add: [plugin4, ...], + * } + */ + getPlugins(target) { + return this.pluginMap[target]; }, - /** */ + /** + * Store Plugins + * + * @param {Array} plugins + */ storePlugins(plugins) { - this.plugins = plugins || []; + this.pluginMap = mapPlugins(plugins || []); }, }; + +/** + * Returns a mapping from plugins to targets and modes + * + * @param {Array} plugins + * @return {Object} - looks like: + * + * + * { + * 'WorkspacePanel': { + * delete: [plugin1, ...], + * replace: [plugin2, ...], + * wrap: [plugin3, ...], + * add: [plugin4, ...], + * }, + * 'Window': { + * delete: [plugin1, ...], + * replace: [plugin2, ...], + * wrap: [plugin3, ...], + * add: [plugin4, ...], + * } + * } + */ +function mapPlugins(plugins) { + return plugins.reduce((map, plugin) => ( + update(map, [plugin.target, plugin.mode], x => [...x || [], plugin]) + ), {}); +} diff --git a/src/extend/withPlugins.js b/src/extend/withPlugins.js index 681eca0a8..7cd175c06 100644 --- a/src/extend/withPlugins.js +++ b/src/extend/withPlugins.js @@ -1,5 +1,6 @@ import React, { Component } from 'react'; import curry from 'lodash/curry'; +import isEmpty from 'lodash/isEmpty'; import { connect } from 'react-redux'; import { pluginStore } from '.'; @@ -8,25 +9,44 @@ function _withPlugins(targetName, TargetComponent) { // eslint-disable-line no-u /** plugin wrapper hoc */ class PluginHoc extends Component { /** render */ - render() { - const plugin = pluginStore.getPlugins().find(p => p.target === targetName); + render() { // eslint-disable-line consistent-return + const plugins = pluginStore.getPlugins(targetName); - if (plugin && plugin.mode === 'delete') { + if (isEmpty(plugins)) { + return <TargetComponent {...this.props} />; + } + + if (!isEmpty(plugins.delete)) { return null; } - if (plugin && plugin.mode === 'replace') { - const PluginComponent = connectPluginComponent(plugin); - return <PluginComponent {...this.props} />; + + if (!isEmpty(plugins.replace)) { + const ReplacePluginComponent = connectPluginComponent(plugins.replace[0]); + return <ReplacePluginComponent {...this.props} />; } - if (plugin && plugin.mode === 'add') { - const PluginComponent = connectPluginComponent(plugin); - return <TargetComponent {...this.props} PluginComponent={PluginComponent} />; + + if (!isEmpty(plugins.wrap) && !isEmpty(plugins.add)) { + const AddPluginComponents = plugins.add.map(plugin => connectPluginComponent(plugin)); + /** we want to pass an react constructor to WrapPluginComponent + rather then a react element */ + const TargetComponentWithAddPlugins = props => ( + <TargetComponent {...props} PluginComponents={AddPluginComponents} /> + ); + const WrapPluginComponent = connectPluginComponent(plugins.wrap[0]); + return ( + <WrapPluginComponent {...this.props} TargetComponent={TargetComponentWithAddPlugins} /> + ); } - if (plugin && plugin.mode === 'wrap') { - const PluginComponent = connectPluginComponent(plugin); - return <PluginComponent {...this.props} TargetComponent={TargetComponent} />; + + if (!isEmpty(plugins.wrap)) { + const WrapPluginComponent = connectPluginComponent(plugins.wrap[0]); + return <WrapPluginComponent {...this.props} TargetComponent={TargetComponent} />; + } + + if (!isEmpty(plugins.add)) { + const AddPluginComponents = plugins.add.map(plugin => connectPluginComponent(plugin)); + return <TargetComponent {...this.props} PluginComponents={AddPluginComponents} />; } - return <TargetComponent {...this.props} />; } } -- GitLab