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