UNPKG

frntnd-class-with-plugins

Version:
283 lines (234 loc) 7.6 kB
import events from 'events'; import _ from 'lodash'; import promiseUtil from 'frntnd-promise-util'; import _plugins from '../singletons/plugins'; const EventEmitter = events.EventEmitter; /** * * @classdesc A basic class that has can be extended using plugins * * @property plugins {Array<String>} Array of plugin names to use in this class * * @param options {Object} the object the instance will be extended with * * @class ClassWithPlugins * @global * */ class ClassWithPlugins { static registerPlugins(obj) { if (typeof obj === 'object' && !Array.isArray(obj)) { _.each(obj, (_obj, type) => { if (typeof _obj === 'object' && !Array.isArray(_obj)) { _.each(_obj, (plugin, name) => { this.registerPlugin(type, name, plugin); }); } }); } } /** * Registers a plugin * @method registerPlugin * @memberof ClassWithPlugins * @static * @param type {String} The type this is a plugin for, mapped onto a Class using the static get _type property on the class * @param name {String} Name of the plugin, this is how the plugin is identified in the system, the name is local to its type * @param plugin {Object} The plugin itself * @returns {*} */ static registerPlugin(type, name, plugin) { const pluginGroup = _plugins[type] = _plugins[type] || {}; if (pluginGroup[name]) { throw new Error(`Can't register ${name} plugin for ${type}, plugin with that name already exists`); } return pluginGroup[name] = plugin; } /** * Retrieves a plugin * @method retrievePlugin * @memberof ClassWithPlugins * @static * @param type {String} The type this is a plugin for * @param name {String} Name of the plugin * @returns {Object|undefined} */ static retrievePlugin(type, name) { if (_plugins[type]) { return _plugins[type][name]; } } /** * Object containing all plugins for all types, in plugins[type][name] hierarchy * @name plugins * @type Object * @memberof ClassWithPlugins * @instance */ get plugins() { return _plugins; } constructor(options = {}) { options._plugins = options.plugins || []; delete options.plugins; _.extend(this, options); this._emitter = new EventEmitter(); this._bindThisContextForAllMethodsInOptions(options); if (this.autoInitialize) { this._initializePlugins(); } } /** * Public API */ /** * Listens for an event trigger by the {@link ClassWithPlugins#trigger} method * @instance * @method on * @memberof ClassWithPlugins * @param event {String} Event to listen to. * @param cb {Function} Function that should be called when this event is triggered. */ on(event, cb) { return this._emitter.on(event, cb); } /** * Triggers an event with data that can be listed to using the {@link ClassWithPlugins#on} method * @instance * @method trigger * @memberof ClassWithPlugins * @param event {String} Event to trigger. * @param data {*} Data the event should trigger with. */ trigger(event, data) { return this._emitter.emit(event, data); } /** * Checks whether this instance has a certain plugin provided using the plugin parameter as a string. * * @param plugin {String} Plugin to look for * @returns {Boolean} * @instance * @method hasPlugin * @memberof ClassWithPlugins */ hasPlugin(plugin) { return this._plugins.indexOf(plugin) !== -1; } /** * This methods registers a hook callback on an instance of a {@link ClassWithPlugins}. * If you want to fire a hook, use that instance (non static) hook method. * @memberof ClassWithPlugins * @method hook * @static * @param event {String} The hook event to hook into * @param cb {Function} Callback function, gets ran when this hook executes * @param context {Object} Context the callbacks should be called with * @param instance {ClassWithPlugins} The instance to hook plugins for */ static hook(event, cb, context, instance) { instance.hooks[event] = instance.hooks[event] || []; if (typeof cb !== 'function') throw new Error(`Can't hook to ${event}, callback is not a function, this is likely caused by a missing hook handler.`); instance.hooks[event].push({cb, context}); } /** * Runs all hook listeners from all plugins active on the class, * returns a promise so plugins can do async stuff and you can wait for the plugins to finish. * * @memberof ClassWithPlugins * @method hook * @instance * @param event {String} Hook event to trigger * @param data {Object} Data to supply the hook callback with, in addition to the instance it's called from * @returns {Promise} */ hook(event, data) { const promises = _.map(this.hooks[event], hook => { let val = hook.cb.call(hook.context, this, data); return promiseUtil.ensurePromise(val); }); return Promise.all(promises); } /** * Private API */ _bindThisContextForAllMethodsInOptions(options) { // get all method names from the options object const bindAllArguments = _.methods(options); if (bindAllArguments.length) { // add this as context argument (the first argument) bindAllArguments.unshift(this); // actually bind the context _.bindAll.apply(_, bindAllArguments); } } /** * * @private */ _initializePlugins() { _.bindAll( this, '_initializePlugin', 'hook' ); this.hooks = this.hooks || {}; this._plugins = this._plugins || []; this._loadedPlugins = []; _.each(this._plugins, (pluginName) => { this._initializePlugin(pluginName); }); } _initializePlugin(pluginName, isDependency) { const plugin = this._getPlugin(pluginName); // dependency already loaded, we don't have to initialize it again if (isDependency && this._dependencyIsLoaded(plugin)) return; if (!plugin) throw new Error(`Plugin '${pluginName}' not found`); this._initializePluginDependencies(plugin); if (this._loadedPlugins.indexOf(plugin) === -1) { this._loadPlugin(plugin); } } _dependencyIsLoaded(plugin) { return ClassWithPlugins._allLoadedPlugins.indexOf(plugin) !== -1; } _loadPlugin(plugin) { const namespace = this._addPluginNamespace(plugin); this._initializeHooks(plugin, namespace); this._exposeProperties(plugin); this._loadedPlugins.push(plugin); ClassWithPlugins._allLoadedPlugins.push(plugin); } _getPlugin(pluginName) { const [type, plugin] = pluginName.split('.'); if (!plugin) { return this.plugins[this.constructor._type][pluginName]; } else { return this.plugins[type][plugin]; } } _initializePluginDependencies(plugin) { _.each(plugin.dependencies, (pluginName) => { this._initializePlugin(pluginName, true); }); } _initializeHooks(plugin, namespace) { _.each(plugin.hooks, (methodName, event) => { this._initializeHook(event, methodName, plugin, namespace); }); } _initializeHook(event, methodName, plugin, namespace) { this.constructor.hook(event, plugin[methodName], namespace || plugin, this); } _exposeProperties(plugin) { _.extend(this, _.pick(plugin, plugin.expose)); } _addPluginNamespace(plugin) { if (plugin.namespace) { return this[plugin.namespace] = _.clone(plugin); } } } ClassWithPlugins.prototype.autoInitialize = true; ClassWithPlugins._allLoadedPlugins = []; export default ClassWithPlugins;