/* global LazyLoader, DUMP */
'use strict';
(function(exports) {
/**
* Turn of this flag to debug all BaseModule based modules.
* @type {Boolean}
*/
var GLOBAL_DEBUG = false;
/**
* This is used to store the constructors which are created
* via BaseModule.create().
* constructor.name => constructor
* @type {Object}
*/
var AVAILABLE_MODULES = {};
/**
* BaseModule is a class skeleton which helps you to build a module with
* * Centralized event handler
* * Centralized settings observation
* * Sub modules management including loading and starting
* * Import preload files
* * DOM rendering
* * Consistent logging function with System boot time and module name
* * Common publishing interface
* @class BaseModule
*/
var BaseModule = function() {};
/**
* The sub modules belong to this module.
* BaseModule will load and then start these sub modules automatically.
* The expressions can include path (e.g. 'path/to/ModuleName'). BaseModule
* will load them from specified subdirectory. However, the module names
* (without path) should be unique even they're under different folders.
* @type {Array}
*/
BaseModule.SUB_MODULES = [];
/**
* All events of need to be listened.
* BaseModule will add/remove the event listener in start/stop functions.
* The function of '_handle_' form in this module will be invoked
* when the event is caught.
* @type {Array}
*/
BaseModule.EVENTS = [];
/**
* All mozSettings need to be observed.
* BaseModule will observe and invoke the responsive
* this['_observe_' + key] function.
* @type {Array}
*/
BaseModule.SETTINGS = [];
/**
* This defines a list of file path needs to be imported
* before the real start of this module.
*/
BaseModule.IMPORTS = [];
/**
* This tells System the sandbox what methods you are going to
* register and let the other to request.
*
* @example
* var MyModule = function() {};
* MyModule.SERVICES = ['unlock'];
* MyModule.prototype = Object.create(BaseModule.prototype);
* MyModule.prototype.constructor = MyModule;
* MyModule.prototype.name = 'MyModule';
* var m = new MyModule();
* m.start();
* // other module
* Service.request('MyModule:unlock').then(function(result) {
* });
* Service.request('unlock').then(function(result) {
* // if the request is registered by only one module.
* });
*/
BaseModule.SERVICES = [];
/**
* The function or property exported here will be
* synchronously queried by other module in system app.
* If we are not started yet, they will get undefined.
*
* @example
* var MyModule = function() {};
* MyModule.STATES = ['isActive'];
* MyModule.prototype = Object.create(BaseModule.prototype);
* MyModule.prototype.constructor = MyModule;
* MyModule.prototype.name = 'MyModule';
* var m = new MyModule();
* m.start();
* // other module
* console.log(Service.query('MyModule.isActive'));
* // if the method name is unique.
* console.log(Service.query('isActive'));
* @type {Array}
*/
BaseModule.STATES = [];
var SubmoduleMixin = {
loadWhenIdle: function(modules) {
return this.service.request('schedule', () => {
this.constructor.SUB_MODULES =
this.constructor.SUB_MODULES.concat(modules);
return this._startSubModules();
});
},
/**
* Helper function to load and start the submodules defined in
* |this.constructor.SUB_MODULES|.
*/
_startSubModules: function() {
if (!this.constructor.SUB_MODULES ||
this.constructor.SUB_MODULES.length === 0) {
return Promise.resolve();
}
var submodules = this.constructor.SUB_MODULES.slice();
var unloaded = [];
submodules.forEach(function(submodule) {
if (BaseModule.defined(submodule) || window[submodule]) {
var name = BaseModule.lowerCapital(submodule);
if (!this[name]) {
this._initializeSubModule(name, submodule);
}
} else {
unloaded.push(submodule);
}
}, this);
if (unloaded.length === 0) {
this.baseSubModuleLoaded && this.baseSubModuleLoaded();
return;
}
this.debug('lazy loading submodules: ' +
unloaded.concat());
return BaseModule.lazyLoad(unloaded).then(() => {
this.debug('lazy loaded submodules: ' +
unloaded.concat());
return Promise.all(
unloaded
.map(BaseModule.parsePath)
.map(function(module) {
var moduleName = BaseModule.lowerCapital(module.name);
if (!this[moduleName]) {
return this._initializeSubModule(moduleName, module.name);
} else {
return Promise.resolve();
}
}, this));
});
},
_initializeSubModule: function(moduleName, module) {
var constructor = AVAILABLE_MODULES[module] || window[module];
if (typeof(constructor) == 'function') {
this.debug('instantiating submodule: ' + moduleName);
this[moduleName] = new constructor(this);
// If there is a custom submodule loaded handler, call it.
// Otherwise we will start the submodule right away.
if (typeof(this['_' + moduleName + '_loaded']) == 'function') {
return this['_' + moduleName + '_loaded']();
} else if (this.lifeCycleState !== 'stopped') {
return this[moduleName].start && this[moduleName].start();
}
} else {
this[moduleName] = constructor;
// For the module which does not become class yet
if (this[moduleName] && this[moduleName].start) {
return this[moduleName].start();
} else if (this[moduleName] && this[moduleName].init) {
// backward compatibility with init.
return this[moduleName].init();
} else {
return undefined;
}
}
},
_stopSubModules: function() {
if (!this.constructor.SUB_MODULES) {
return;
}
this.constructor.SUB_MODULES.map(BaseModule.parsePath)
.forEach(function(module) {
var moduleName = BaseModule.lowerCapital(module.name);
if (this[moduleName]) {
this.debug('Stopping submodule: ' + moduleName);
this[moduleName].stop && this[moduleName].stop();
}
}, this);
}
};
/**
* SettingsMixin will provide you the ability to watch
* and store the settings in this._settings
* @type {Object}
*/
var SettingMixin = {
observe: function(name, value) {
this.debug('observing ' + name + ' : ' + value);
this._settings[name] = value;
if (typeof(this['_observe_' + name]) == 'function') {
this.debug('observer for ' + name + ' found, invoking.');
this['_observe_' + name](value);
}
},
_observeSettings: function() {
if (!this.constructor.SETTINGS) {
this.debug('No settings needed, skipping.');
return;
}
this._settings = {};
this.debug('~observing following settings: ' +
this.constructor.SETTINGS.concat());
this.constructor.SETTINGS.forEach(function(setting) {
this.service.request('SettingsCore:addObserver', setting, this);
}, this);
},
_unobserveSettings: function() {
if (!this.constructor.SETTINGS) {
return;
}
this.constructor.SETTINGS.forEach(function(setting) {
this.service.request('SettingsCore:removeObserver', setting, this);
}, this);
}
};
var EventMixin = {
/**
* Custom global event handler before the event is handled
* by a specific handler.
* Override it if necessary.
*/
_pre_handleEvent: function() {
},
/**
* Custom global event handler after the event is handled.
* Override it if necessary.
*/
_post_handleEvent: function() {
},
_subscribeEvents: function() {
if (!this.constructor.EVENTS) {
this.debug('No events wanted, skipping.');
return;
}
this.debug('event subcribing stage..');
this.constructor.EVENTS.forEach(function(event) {
this.debug('subscribing ' + event);
window.addEventListener(event, this);
}, this);
},
_unsubscribeEvents: function() {
if (!this.constructor.EVENTS) {
return;
}
this.constructor.EVENTS.forEach(function(event) {
window.removeEventListener(event, this);
}, this);
},
handleEvent: function(evt) {
if (typeof(this._pre_handleEvent) == 'function') {
var shouldContinue = this._pre_handleEvent(evt);
if (shouldContinue === false) {
return;
}
} else {
this.debug('no handle event pre found. skip');
}
if (typeof(this['_handle_' + evt.type]) == 'function') {
this.debug('handling ' + evt.type);
this['_handle_' + evt.type](evt);
}
if (typeof(this._post_handleEvent) == 'function') {
this._post_handleEvent(evt);
}
}
};
var ServiceMixin = {
_registerServices: function() {
if (!this.constructor.SERVICES) {
return;
}
this.constructor.SERVICES.forEach(function(service) {
this.service.register(service, this);
}, this);
},
_unregisterServices: function() {
if (!this.constructor.SERVICES) {
return;
}
this.constructor.SERVICES.forEach(function(service) {
this.service.unregister(service, this);
}, this);
}
};
var StateMixin = {
_registerStates: function() {
if (!this.constructor.STATES) {
return;
}
this.constructor.STATES.forEach(function(state) {
this.service.registerState(state, this);
}, this);
},
_unregisterStates: function() {
if (!this.constructor.STATES) {
return;
}
this.constructor.STATES.forEach(function(state) {
this.service.unregisterState(state, this);
}, this);
}
};
BaseModule.defined = function(name) {
return !!AVAILABLE_MODULES[name];
};
BaseModule.__clearDefined = function() {
AVAILABLE_MODULES = [];
};
/**
* Mixin the prototype with give mixin object.
* @param {Object} prototype The prototype of a class
* @param {Object} mixin An object will be mixed into the prototype
*/
BaseModule.mixin = function (prototype, mixin) {
for (var prop in mixin) {
if (mixin.hasOwnProperty(prop)) {
prototype[prop] = mixin[prop];
}
}
};
/**
* Create a module based on base module and give properties.
* The constructor will be placed in AVAILABLE_MODULES if you
* specify an unique name in the prototype.
* @example
* var MyModule = function() {};
* BaseModule.create(MyModule, {
* name: 'MyModule'
* });
* var myModule = BaseModule.instantiate('MyModule');
*
* @param {Function} constructor The constructor function.
* @param {Object} prototype
* The prototype which will be injected into the class.
* @param {Object} properties
* The property object which includes getter/setter.
*/
BaseModule.create = function(constructor, prototype, properties) {
constructor.prototype = Object.create(BaseModule.prototype, properties);
constructor.prototype.constructor = constructor;
if (constructor.SETTINGS) {
BaseModule.mixin(constructor.prototype, SettingMixin);
}
if (constructor.EVENTS) {
BaseModule.mixin(constructor.prototype, EventMixin);
}
if (constructor.SERVICES) {
BaseModule.mixin(constructor.prototype, ServiceMixin);
}
if (constructor.STATES) {
BaseModule.mixin(constructor.prototype, StateMixin);
}
// Inject this anyway.
BaseModule.mixin(constructor.prototype, SubmoduleMixin);
if (prototype) {
BaseModule.mixin(constructor.prototype, prototype);
if (prototype.name) {
AVAILABLE_MODULES[prototype.name] = constructor;
} else {
console.warn('No name give, impossible to instantiate without name.');
}
}
return constructor;
};
/**
* Create a new instance based on the module name given.
* It will look up |AVAILABLE_MODULES|.
* Note: this will instante multiple instances if called more than once.
* Also it's impossible to pass arguments now.
* @param {String} moduleName The module name
* which comes from the prototype of the module.
* @return {Object} Created instance.
*/
BaseModule.instantiate = function(moduleName) {
if (moduleName in AVAILABLE_MODULES) {
var args = Array.prototype.slice.call(arguments, 1);
var constructor = function() {
AVAILABLE_MODULES[moduleName].apply(this, args);
};
constructor.prototype = AVAILABLE_MODULES[moduleName].prototype;
return new constructor();
}
return undefined;
};
/**
* Lazy load an list of modules
* @param {Array} array A list of module names
* @return {Promise} The promise of lazy loading;
* it will be invoked once lazy loading is done.
*/
BaseModule.lazyLoad = function(array) {
var self = this;
return new Promise(function(resolve) {
var fileList = [];
array.forEach(function(module) {
fileList.push(BaseModule.object2fileName(module));
}, self);
LazyLoader.load(fileList, function() {
resolve();
});
});
};
/**
* A helper function to lowercase only the capital character.
* @example
* Service.lowerCapital('AppWindowManager');
* // appWindowManager
* @param {String} str String to be lowercased on capital
* @return {String} Captital lowerred string
*/
BaseModule.lowerCapital = function(str) {
return str.charAt(0).toLowerCase() + str.slice(1);
};
/**
* A helper function to split module expressions into "path" and "name".
* @example
* Service.parsePath('path/to/ModuleName');
* // {path: 'path/to/', name: 'ModuleName'}
* @param {String} str String to be splitted
* @return {Object} The result object with members: "path" and "name".
*/
BaseModule.parsePath = function(str) {
var [, path, name] = /^(.*\/|)(.+)$/.exec(str);
return {
path: path,
name: name
};
};
/**
* A helper function to transform object name to file name
* @example
* Service.object2fileName('AppWindowManager');
* // 'js/app_window_manager.js'
* Service.object2fileName('path/to/ModuleName');
* // 'js/path/to/module_name.js'
*
* @param {String} string Module name
* @return {String} File name
*/
BaseModule.object2fileName = function(string) {
var i = 0;
var ch = '';
var module = BaseModule.parsePath(string);
var moduleName = module.name;
while (i <= moduleName.length) {
var character = moduleName.charAt(i);
if (character !== character.toLowerCase()) {
if (ch === '') {
ch += character.toLowerCase();
} else {
ch += '_' + character.toLowerCase();
}
} else {
ch += character;
}
i++;
}
return '/js/' + module.path + ch + '.js';
};
BaseModule.prototype = {
service: window.Service,
DEBUG: false,
TRACE: false,
/**
* The name of this module which is usually the constructor function name
* and could be converted to the file name.
* For example, AppWindowManager will be mapped to app_window_manager.js
* This should be unique.
* @type {String}
*/
name: '(Anonymous)',
EVENT_PREFIX: '',
/**
* We are having three states:
* * starting
* * started
* * stopped
* @type {String}
*/
lifeCycleState: 'stopped',
publish: function(event, detail, noPrefix) {
var prefix = noPrefix ? '' : this.EVENT_PREFIX;
var evt = new CustomEvent(prefix + event,
{
bubbles: true,
detail: detail || this
});
this.debug('publishing: ' + prefix + event);
window.dispatchEvent(evt);
},
/**
* Basic log.
*/
debug: function() {
if (this.DEBUG || GLOBAL_DEBUG) {
console.log('[' + this.name + ']' +
'[' + this.service.currentTime() + '] ' +
Array.slice(arguments).concat());
if (this.TRACE) {
console.trace();
}
} else if (window.DUMP) {
DUMP('[' + this.name + ']' +
'[' + this.service.currentTime() + '] ' +
Array.slice(arguments).concat());
}
},
/**
* Log some infomation.
*/
info: function() {
if (this.DEBUG || GLOBAL_DEBUG) {
console.info('[' + this.name + ']' +
'[' + this.service.currentTime() + '] ' +
Array.slice(arguments).concat());
}
},
/**
* Log some warning message.
*/
warn: function() {
if (this.DEBUG || GLOBAL_DEBUG) {
console.warn('[' + this.name + ']' +
'[' + this.service.currentTime() + '] ' +
Array.slice(arguments).concat());
}
},
/**
* Log some error message.
*/
error: function() {
if (this.DEBUG || GLOBAL_DEBUG) {
console.error('[' + this.name + ']' +
'[' + this.service.currentTime() + '] ' +
Array.slice(arguments).concat());
}
},
writeSetting: function(settingObject) {
this.debug('writing ' + JSON.stringify(settingObject) +
' to settings db');
return this.service.request('SettingsCore:set', settingObject);
},
readSetting: function(name) {
if (this._settings && this._settings[name]) {
return Promise.resolve(this._settings[name]);
} else {
this.debug('reading ' + name + ' from settings db');
return this.service.request('SettingsCore:get', name);
}
},
/**
* Custom start function, override it if necessary.
* If you want to block the start process of this module,
* return a promise here.
*/
_start: function() {},
/**
* Custom stop function. Override it if necessary.
*/
_stop: function() {},
/**
* The starting progress of a module has these steps:
* * import javascript files
* * lazy load submodules and instantiate once loaded.
* * custom start function
* * attach event listeners
* * observe settings
* * register services to System
* * DOM elements rendering (not implemented)
* The import is guranteed to happen before anything else.
* The service registration is expected to happen after everything is done.
* The ordering of the remaining parts should not depends each other.
*
* The start function will return a promise to let you know
* when the module is started.
* @example
* var a = BaseModule.instantiate('A');
* a.start().then(() => {
* console.log('started');
* });
*
* Note: a module start promise will only be resolved
* after all the steps are resolved, including
* the custom start promise and the promises from all the submodules.
* So when you see a module is started, that means all of its
* submodules are started as well.
*
* @memberOf BaseModule.prototype
*/
start: function() {
if (this.lifeCycleState !== 'stopped') {
this.warn('already started');
return Promise.reject('already started');
}
this.switchLifeCycle('starting');
return this.imports();
},
__imported: function() {
// Do nothing if we are stopped.
if (this.lifeCycleState === 'stopped') {
this.warn('already stopped');
return Promise.resolve();
}
this.debug('in imported');
return Promise.all([
// Parent module needs to know the events from the submodule.
this._subscribeEvents && this._subscribeEvents(),
this._startSubModules && this._startSubModules(),
this._start(),
this._observeSettings && this._observeSettings(),
this._registerServices && this._registerServices(),
this._registerStates && this._registerStates()]).then(() => {
this.switchLifeCycle('started');
});
},
/**
* The stopping of a module has these steps:
* * unregister services to System sandbox
* * lazy load submodules and instantiate once loaded.
* * attach event listeners
* * observe settings
* * custom stop function
*/
stop: function() {
if (this.lifeCycleState === 'stopped') {
this.warn('already stopped');
return;
}
this._unregisterServices && this._unregisterServices();
this._unregisterStates && this._unregisterStates();
this._stopSubModules && this._stopSubModules();
this._unsubscribeEvents && this._unsubscribeEvents();
this._unobserveSettings && this._unobserveSettings();
this._stop();
this.switchLifeCycle('stopped');
},
switchLifeCycle: function(state) {
if (this.lifeCycleState === state) {
return;
}
this.debug('life cycle state change: ' +
this.lifeCycleState + ' -> ' + state);
this.lifeCycleState = state;
this.publish(state);
},
imports: function() {
if (!this.constructor.IMPORTS ||
typeof(this.constructor.IMPORTS) == 'undefined' ||
this.constructor.IMPORTS.length === 0) {
return this.__imported();
}
this.debug(this.constructor.IMPORTS);
this.debug('import loading.');
return LazyLoader.load(this.constructor.IMPORTS)
.then(() => {
this.debug('imported..');
return this.__imported();
});
}
};
exports.BaseModule = BaseModule;
}(window));