frntnd-class-with-plugins
Version:
Class that can be extended using plugins
283 lines (234 loc) • 7.6 kB
JavaScript
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;