diff --git a/__tests__/integration/mirador/plugins/validate.html b/__tests__/integration/mirador/plugins/validate.html new file mode 100644 index 0000000000000000000000000000000000000000..8906af8c971bd0cbece28fdab8e8ba6001ef9a35 --- /dev/null +++ b/__tests__/integration/mirador/plugins/validate.html @@ -0,0 +1,100 @@ +<!DOCTYPE html> +<html lang="en" dir="ltr"> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <meta name="theme-color" content="#000000"> + <title>Mirador</title> + </head> + <body> + <div id="mirador" style="position: absolute; top: 0; bottom: 0; left: 0; right: 0;"></div> + <script src="../../../../node_modules/react/umd/react.development.js"></script> + <script src="../../../../node_modules/react-dom/umd/react-dom.development.js"></script> + <script>document.write("<script type='text/javascript' src='../../../../dist/mirador.min.js?v=" + Date.now() + "'><\/script>");</script> + <script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script> + <script type="text/babel"> + + const config = { id: 'mirador' }; + + const validPluginA = { + target: 'WorkspaceControlPanelButtons', + mode: 'add', + component: props => (<div id="valid-plugin-a" />), + }; + + const validPluginB = { + name: 'validPluginB', + target: 'WorkspaceControlPanelButtons', + mode: 'add', + component: props => (<div id="valid-plugin-b" />), + mapStateToProps: () => ({}), + mapDispatchToProps: {}, + reducers: { + bar: (state = null, action) => state, + }, + }; + + const invalidPluginA = { + name: 'invalidPluginA', + target: 'WorkspaceControlPanelButtons', + mode: 'LURK', // invalid + component: props => (<div id="invalid-plugin-a" />), + }; + + const invalidPluginB = { + name: 'invalidPluginB', + target: x => x, // invalid + mode: 'add', + component: props => (<div id="invalid-plugin-b" />), + }; + + const invalidPluginC = { + name: 'invalidPluginC', + target: 'WorkspaceControlPanelButtons', + mode: 'add', + component: props => (<div id="invalid-plugin-c" />), + mapStateToProps: {}, // invalid + }; + + const invalidPluginD = { + name: 'invalidPluginD', + target: 'WorkspaceControlPanelButtons', + mode: 'add', + component: props => (<div id="invalid-plugin-d" />), + mapDispatchToProps: "foo" // invalid + }; + + const invalidPluginE = { + name: 'invalidPluginE', + target: 'WorkspaceControlPanelButtons', + mode: 'add', + component: props => (<div id="invalid-plugin-e" />), + reducers: 3, // invalid + }; + + const invalidPluginF = { + name: 'invalidPluginF', + target: 'WorkspaceControlPanelButtons', + mode: 'add', + component: props => (<div id="invalid-plugin-f" />), + reducers: { + foo: "foo", // invalid + }, + }; + + + + const miradorInstance = Mirador.viewer(config, [ + validPluginA, + validPluginB, + invalidPluginA, + invalidPluginB, + invalidPluginC, + invalidPluginD, + invalidPluginE, + invalidPluginF, + ]); + + </script> + </body> +</html> diff --git a/__tests__/integration/mirador/plugins/validate.test.js b/__tests__/integration/mirador/plugins/validate.test.js new file mode 100644 index 0000000000000000000000000000000000000000..394257f726e40d18d41edcdc70a133a4b5476856 --- /dev/null +++ b/__tests__/integration/mirador/plugins/validate.test.js @@ -0,0 +1,22 @@ + +describe('pass valid and invalid plugins to <WorkspaceControlPanelButtons>', () => { + beforeAll(async () => { + await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/plugins/validate.html'); + await expect(page).toMatchElement('.mirador-viewer'); + await page.waitFor(1000); + }); + + it('valid plugins will be applied <WorkspaceControlPanelButtons>', async () => { + await expect(page).toMatchElement('.mirador-workspace-control-panel-buttons #valid-plugin-a'); + await expect(page).toMatchElement('.mirador-workspace-control-panel-buttons #valid-plugin-b'); + }); + + it('invalid plugins will not be applied <WorkspaceControlPanelButtons>', async () => { + await expect(page).not.toMatchElement('.mirador-workspace-control-panel-buttons #invalid-plugin-a'); + await expect(page).not.toMatchElement('.mirador-workspace-control-panel-buttons #invalid-plugin-b'); + await expect(page).not.toMatchElement('.mirador-workspace-control-panel-buttons #invalid-plugin-c'); + await expect(page).not.toMatchElement('.mirador-workspace-control-panel-buttons #invalid-plugin-d'); + await expect(page).not.toMatchElement('.mirador-workspace-control-panel-buttons #invalid-plugin-e'); + await expect(page).not.toMatchElement('.mirador-workspace-control-panel-buttons #invalid-plugin-f'); + }); +}); diff --git a/__tests__/src/extend/pluginStore.test.js b/__tests__/src/extend/pluginStore.test.js index c66e74d5004724c76cecdd01201893e94b7c1282..fa34f945c0f6665062bb261cafba5c809fda22ce 100644 --- a/__tests__/src/extend/pluginStore.test.js +++ b/__tests__/src/extend/pluginStore.test.js @@ -14,40 +14,63 @@ describe('getPlugins', () => { pluginStore.storePlugins(); expect(pluginStore.getPlugins('target')).not.toBeDefined(); }); + it('returns mode->plugins mapping for target', () => { + /** */ + const component = x => x; + const plugins = [ - { mode: 'wrap', target: 'Window' }, - { mode: 'wrap', target: 'Window' }, - { mode: 'add', target: 'Window' }, - { mode: 'add', target: 'Window' }, - - { mode: 'wrap', target: 'TopBar' }, - { mode: 'wrap', target: 'TopBar' }, - { mode: 'add', target: 'TopBar' }, - { mode: 'add', target: 'TopBar' }, + { component, mode: 'wrap', target: 'Window' }, + { component, mode: 'wrap', target: 'Window' }, + { component, mode: 'add', target: 'Window' }, + { component, mode: 'add', target: 'Window' }, + + { component, mode: 'wrap', target: 'TopBar' }, + { component, mode: 'wrap', target: 'TopBar' }, + { component, mode: 'add', target: 'TopBar' }, + { component, mode: 'add', target: 'TopBar' }, ]; pluginStore.storePlugins(plugins); expect(pluginStore.getPlugins('Window')).toEqual({ add: [ - { mode: 'add', target: 'Window' }, - { mode: 'add', target: 'Window' }, + { component, mode: 'add', target: 'Window' }, + { component, mode: 'add', target: 'Window' }, ], wrap: [ - { mode: 'wrap', target: 'Window' }, - { mode: 'wrap', target: 'Window' }, + { component, mode: 'wrap', target: 'Window' }, + { component, mode: 'wrap', target: 'Window' }, ], }); expect(pluginStore.getPlugins('TopBar')).toEqual({ add: [ - { mode: 'add', target: 'TopBar' }, - { mode: 'add', target: 'TopBar' }, + { component, mode: 'add', target: 'TopBar' }, + { component, mode: 'add', target: 'TopBar' }, ], wrap: [ - { mode: 'wrap', target: 'TopBar' }, - { mode: 'wrap', target: 'TopBar' }, + { component, mode: 'wrap', target: 'TopBar' }, + { component, mode: 'wrap', target: 'TopBar' }, + ], + }); + }); + + // see also pluginValidation.test.js + it('filter out invalid plugins', () => { + /** */ + const component = x => x; + + const plugins = [ + { component, mode: 'add', target: 'Window' }, + { component, mode: 'LURK', target: 'Window' }, + ]; + + pluginStore.storePlugins(plugins); + + expect(pluginStore.getPlugins('Window')).toEqual({ + add: [ + { component, mode: 'add', target: 'Window' }, ], }); }); diff --git a/__tests__/src/extend/pluginValidation.test.js b/__tests__/src/extend/pluginValidation.test.js new file mode 100644 index 0000000000000000000000000000000000000000..235e9ca16a6c9394965ecf21bfb991b57b29a7a0 --- /dev/null +++ b/__tests__/src/extend/pluginValidation.test.js @@ -0,0 +1,108 @@ +import { validatePlugin } from '../../../src/extend'; + +/** */ +const createPlugin = props => ({ + component: x => x, + mapDispatchToProps: x => x, + mapStateToProps: x => x, + mode: 'add', + name: 'test', + reducers: { + bar: x => x, + foo: x => x, + }, + target: 'Window', + ...props, +}); + +describe('validatePlugin', () => { + it('return true if plugin is valid', () => { + const plugin = createPlugin(); + expect(validatePlugin(plugin)).toBe(true); + }); + + it('plugin must be a object', () => { + const plugin = []; + expect(validatePlugin(plugin)).toBe(false); + }); + + it('name must be undefined or string', () => { + let plugin = createPlugin({ name: undefined }); + expect(validatePlugin(plugin)).toBe(true); + plugin = createPlugin({ name: 'test' }); + expect(validatePlugin(plugin)).toBe(true); + plugin = createPlugin({ name: [] }); + expect(validatePlugin(plugin)).toBe(false); + }); + + it('target must be string', () => { + let plugin = createPlugin({ target: undefined }); + expect(validatePlugin(plugin)).toBe(false); + plugin = createPlugin({ target: 'test' }); + expect(validatePlugin(plugin)).toBe(true); + plugin = createPlugin({ target: [] }); + expect(validatePlugin(plugin)).toBe(false); + }); + + it('mode must be "add" or "wrap"', () => { + let plugin = createPlugin({ mode: undefined }); + expect(validatePlugin(plugin)).toBe(false); + plugin = createPlugin({ mode: 'somethink' }); + expect(validatePlugin(plugin)).toBe(false); + plugin = createPlugin({ mode: 'add' }); + expect(validatePlugin(plugin)).toBe(true); + plugin = createPlugin({ mode: 'wrap' }); + expect(validatePlugin(plugin)).toBe(true); + }); + + it('component must be function', () => { + let plugin = createPlugin({ component: undefined }); + expect(validatePlugin(plugin)).toBe(false); + plugin = createPlugin({ component: 'somethink' }); + expect(validatePlugin(plugin)).toBe(false); + plugin = createPlugin({ component: x => x }); + expect(validatePlugin(plugin)).toBe(true); + }); + + it('mapStateToProps must be undefined, null or function', () => { + let plugin = createPlugin({ mapStateToProps: undefined }); + expect(validatePlugin(plugin)).toBe(true); + plugin = createPlugin({ mapStateToProps: x => x }); + expect(validatePlugin(plugin)).toBe(true); + plugin = createPlugin({ mapStateToProps: null }); + expect(validatePlugin(plugin)).toBe(true); + plugin = createPlugin({ mapStateToProps: 'something' }); + expect(validatePlugin(plugin)).toBe(false); + }); + + it('mapDispatchToProps must be undefined, null, function or object', () => { + let plugin = createPlugin({ mapDispatchToProps: undefined }); + expect(validatePlugin(plugin)).toBe(true); + plugin = createPlugin({ mapDispatchToProps: x => x }); + expect(validatePlugin(plugin)).toBe(true); + plugin = createPlugin({ mapDispatchToProps: {} }); + expect(validatePlugin(plugin)).toBe(true); + plugin = createPlugin({ mapDispatchToProps: null }); + expect(validatePlugin(plugin)).toBe(true); + plugin = createPlugin({ mapDispatchToProps: 'something' }); + expect(validatePlugin(plugin)).toBe(false); + }); + + it('reducers must be undefined or object', () => { + let plugin = createPlugin({ reducers: undefined }); + expect(validatePlugin(plugin)).toBe(true); + plugin = createPlugin({ reducers: {} }); + expect(validatePlugin(plugin)).toBe(true); + plugin = createPlugin({ reducers: 'something' }); + expect(validatePlugin(plugin)).toBe(false); + }); + + it('each reducer must be a function', () => { + let reducers = { bar: x => x, foo: x => x }; + let plugin = createPlugin({ reducers }); + expect(validatePlugin(plugin)).toBe(true); + reducers = { bar: x => x, foo: undefined }; + plugin = createPlugin({ reducers }); + expect(validatePlugin(plugin)).toBe(false); + }); +}); diff --git a/src/extend/index.js b/src/extend/index.js index 661d66ff66f8264b9fa5fe425ebe6cb277a67be1..d534dcaffb6e8c4ed65d661bc2cb94c63836b39b 100644 --- a/src/extend/index.js +++ b/src/extend/index.js @@ -1,2 +1,3 @@ export * from './pluginStore'; export * from './withPlugins'; +export * from './pluginValidation'; diff --git a/src/extend/pluginStore.js b/src/extend/pluginStore.js index 437d07b15daed26f641e0eee734d246f523dff9c..0b191b3dd1e59a54f5bd57c6b81d912b5b95fb83 100644 --- a/src/extend/pluginStore.js +++ b/src/extend/pluginStore.js @@ -1,4 +1,5 @@ import update from 'lodash/update'; +import { validatePlugin } from '.'; export const pluginStore = { /** @@ -20,8 +21,10 @@ export const pluginStore = { * * @param {Array} plugins */ - storePlugins(plugins) { - this.pluginMap = mapPlugins(plugins || []); + storePlugins(plugins = []) { + const { validPlugins, invalidPlugins } = filterPlugins(plugins); + logInvalidPlugins(invalidPlugins); + this.pluginMap = mapPlugins(validPlugins); }, }; @@ -48,3 +51,21 @@ function mapPlugins(plugins) { update(map, [plugin.target, plugin.mode], x => [...x || [], plugin]) ), {}); } + +/** */ +function filterPlugins(plugins) { + const filteredPlugins = { invalidPlugins: [], validPlugins: [] }; + plugins.forEach(plugin => ( + validatePlugin(plugin) + ? filteredPlugins.validPlugins.push(plugin) + : filteredPlugins.invalidPlugins.push(plugin) + )); + return filteredPlugins; +} + +/** */ +function logInvalidPlugins(plugins) { + plugins.forEach(plugin => ( + console.log(`Mirador: Plugin ${plugin.name} is not valid and was rejected.`) + )); +} diff --git a/src/extend/pluginValidation.js b/src/extend/pluginValidation.js new file mode 100644 index 0000000000000000000000000000000000000000..2f70d050f7795653cf2cf47cae0372c8e8f79619 --- /dev/null +++ b/src/extend/pluginValidation.js @@ -0,0 +1,69 @@ +import isString from 'lodash/isString'; +import isUndefined from 'lodash/isUndefined'; +import isFunction from 'lodash/isFunction'; +import isObject from 'lodash/isObject'; +import isNull from 'lodash/isNull'; +import values from 'lodash/values'; + +/** */ +export const validatePlugin = plugin => [ + checkPlugin, + checkName, + checkTarget, + checkMode, + checkComponent, + checkMapStateToProps, + checkMapDispatchToProps, + checkReducers, +].every(check => check(plugin)); + +/** */ +const checkPlugin = plugin => isObject(plugin); + +/** */ +const checkName = (plugin) => { + const { name } = plugin; + return isUndefined(name) || isString(name); +}; + +/** */ +const checkTarget = (plugin) => { + const { target } = plugin; + return isString(target); +}; + +/** */ +const checkMode = (plugin) => { + const { mode } = plugin; + return ['add', 'wrap'].some(s => s === mode); +}; + +/** */ +const checkComponent = (plugin) => { + const { component } = plugin; + return isFunction(component); +}; + +/** */ +const checkMapStateToProps = (plugin) => { + const { mapStateToProps } = plugin; + return isUndefined(mapStateToProps) + || isNull(mapStateToProps) + || isFunction(mapStateToProps); +}; + +/** */ +const checkMapDispatchToProps = (plugin) => { + const { mapDispatchToProps } = plugin; + return isUndefined(mapDispatchToProps) + || isNull(mapDispatchToProps) + || isFunction(mapDispatchToProps) + || isObject(mapDispatchToProps); +}; + +/** */ +const checkReducers = (plugin) => { + const { reducers } = plugin; + return isUndefined(reducers) + || (isObject(reducers) && values(reducers).every(isFunction)); +};