From 52c66893ab3987205de5b7505df0156ad8eac9eb Mon Sep 17 00:00:00 2001 From: Matthias Jentsch Date: Tue, 20 Aug 2013 12:00:41 +0200 Subject: [PATCH] Add new component loader to load frontend-components Add a component loader that finds components by searching for elements with the "data-icinga-component" attribute and loads the corresponding JavaScript file from the backend to render the component. refs #4456 --- application/controllers/StaticController.php | 15 +- public/js/icinga/component.js | 142 ++++++++++++++++++ public/js/icinga/components/datetime.js | 21 ++- public/js/icinga/icinga.js | 12 +- public/js/icinga/registry.js | 137 +++++++++++++++++ public/js/icinga/util/logging.js | 4 + test/js/test/icinga/componentTest.js | 148 +++++++++++++++++++ test/js/test/icinga/moduleTest.js | 3 + test/js/test/icinga/registryTest.js | 117 +++++++++++++++ test/js/testlib/requiremock.js | 14 +- 10 files changed, 590 insertions(+), 23 deletions(-) create mode 100644 public/js/icinga/component.js create mode 100644 public/js/icinga/registry.js create mode 100644 test/js/test/icinga/componentTest.js create mode 100644 test/js/test/icinga/registryTest.js diff --git a/application/controllers/StaticController.php b/application/controllers/StaticController.php index e40201dec..a5b229d68 100644 --- a/application/controllers/StaticController.php +++ b/application/controllers/StaticController.php @@ -111,13 +111,18 @@ class StaticController extends ActionController $module = $this->_getParam('module_name'); $file = $this->_getParam('file'); - if (!Icinga::app()->getModuleManager()->hasEnabled($module)) { - echo '/** Module not enabled **/'; - return; + if ($module == 'app') { + $basedir = Icinga::app()->getApplicationDir('../public/js/icinga/components/'); + $filePath = $basedir . $file; + } else { + if (!Icinga::app()->getModuleManager()->hasEnabled($module)) { + echo "/** Module not enabled **/"; + return; + } + $basedir = Icinga::app()->getModuleManager()->getModule($module)->getBaseDir(); + $filePath = $basedir . '/public/js/' . $file; } - $basedir = Icinga::app()->getModuleManager()->getModule($module)->getBaseDir(); - $filePath = $basedir . '/public/js/' . $file; if (!file_exists($filePath)) { echo '/** Module has no js files **/'; return; diff --git a/public/js/icinga/component.js b/public/js/icinga/component.js new file mode 100644 index 000000000..3b181ae35 --- /dev/null +++ b/public/js/icinga/component.js @@ -0,0 +1,142 @@ +/** + * {{LICENSE_HEADER}} + * {{LICENSE_HEADER}} + */ + +/** + * A module to load, manage and query frontend components + * + */ +define(['jquery', 'logging', 'icinga/registry'], function ($, log, registry) { + "use strict"; + + var Manager = function() { + + /** + * Load the component with the given type and attach it to the target + * + * @param {String} cmpType The component type to load '/' + * @param {HTMLElement} target The targeted dom node + * @param {function} fin The called when the component was successfully loaded + * @param {function} err The error-callback + */ + var loadComponent = function(cmpType, target, fin, err) { + requirejs( + ['modules/'+cmpType], + function (Cmp) { + var cmp; + try { + cmp = new Cmp(target); + } catch (e) { + log.emergency(e); + err(e); + return; + } + if (fin) { + fin(cmp); + } + }, + function (ex) { + if (!ex) { + return; + } + log.emergency('Component "'+cmpType+'" could not be loaded.', ex); + if (err) { + err(ex); + } + } + ); + }; + + /** + * Load all new components and remove components that were removed from + * the DOM + * + * @param {function} fin Called when the loading is completed + */ + this.load = function(fin) { + + /* + * Count the amount of pending callbacks to make sure everything is loaded + * when calling the garbage collection. + */ + var pendingFns = 1; + + var finalize = function() { + pendingFns--; + /* + * Only return when all components are loaded + */ + if (pendingFns === 0) { + registry.removeInactive(); + if (fin) { + fin(); + } + } + }; + + registry.markAllInactive(); + + $('div[data-icinga-component]') + .each(function(index, el) { + var type = $(el).attr('data-icinga-component'); + pendingFns++; + loadComponent( + type, + el, + function(cmp) { + var id = registry.add(cmp, el.id, type); + registry.markActive(id); + el.id = id; + finalize(); + }, + finalize + ); + }); + finalize(); + }; + + /** + * Get the id of the given component, if one is assigned + * + * @param {*} component The component of which the id should be retrieved + * + * @returns {String|null} The id of the component, or null + */ + this.getId = function(component) { + return registry.getId(component); + }; + + /** + * Get the component that is assigned to the given id + * + * @param {String} id The id of the component + * + * @returns {*} The component or null + */ + this.getById = function(id) { + return registry.getById(id); + }; + + /** + * Get all components that match the given type + * + * @param {String} type The component type in the form '/' + * + * @returns {*|Array} The components or an empty array + */ + this.getByType = function(type) { + return registry.getByType(type); + }; + + /** + * Get all components + * + * @returns {*|Array} The components or an empty array + */ + this.getComponents = function() { + return registry.getComponents(); + }; + }; + return new Manager(); +}); diff --git a/public/js/icinga/components/datetime.js b/public/js/icinga/components/datetime.js index 7601f8a34..aa137c9b2 100644 --- a/public/js/icinga/components/datetime.js +++ b/public/js/icinga/components/datetime.js @@ -6,16 +6,15 @@ define(['jquery', 'datetimepicker'], function($) { "use strict"; - var DateTimeBehaviour = function() { - this.enable = function() { - $('.datetime input') - .attr('data-format', 'yyyy-MM-dd hh:mm:ss'); - $('.datetime') - .addClass('input-append') - .append('' + - '') - .datetimepicker(); - } + var DateTimePicker = function(target) { + $(target).find('.datetime input') + .attr('data-format', 'yyyy-MM-dd hh:mm:ss'); + + $(target).find('.datetime') + .addClass('input-append') + .append('' + + '') + .datetimepicker(); }; - return new DateTimeBehaviour(); + return DateTimePicker; }); diff --git a/public/js/icinga/icinga.js b/public/js/icinga/icinga.js index 20fb3ccb4..275e04c5a 100755 --- a/public/js/icinga/icinga.js +++ b/public/js/icinga/icinga.js @@ -5,10 +5,15 @@ define([ 'icinga/module', 'icinga/util/async', 'icinga/container', - 'modules/list' -], function ($, log, moduleMgr, async, containerMgr, modules) { + 'modules/list', + 'icinga/component' +], function ($, log, moduleMgr, async, containerMgr, modules,components) { 'use strict'; + $(document).ready(function(){ + components.load(); + }); + /** * Icinga prototype */ @@ -62,9 +67,6 @@ define([ $(document).ready(initialize.bind(this)); return { - /** - * - */ loadModule: function(blubb,bla) { behaviour.registerBehaviour(blubb,bla); }, diff --git a/public/js/icinga/registry.js b/public/js/icinga/registry.js new file mode 100644 index 000000000..81096fc50 --- /dev/null +++ b/public/js/icinga/registry.js @@ -0,0 +1,137 @@ +/** + * {{LICENSE_HEADER}} + * {{LICENSE_HEADER}} + */ + +/** + * A component registry that maps components to unique IDs and keeps track + * of component types to allow easy querying + * + */ +define(['jquery'], function($) { + "use strict"; + + var Registry = function() { + var self = this; + + /** + * Map ids to components + */ + var components = {}; + + /** + * Generate a new component id + */ + var createId = (function() { + var id = 0; + return function() { + return 'icinga-component-'+id++; + }; + })(); + + /** + * Get the id of the given component, if one is assigned + * + * @param {*} component The component of which the id should be retrieved + * + * @returns {String|null} The id of the component, or null + */ + this.getId = function(cmp) { + var id = null; + $.each(components, function(key, value) { + if (value && value.cmp === cmp) { + id = key; + } + }); + return id; + }; + + /** + * Get the component that is assigned to the given id + * + * @param {String} id The id of the component + * + * @returns {*} The component or null + */ + this.getById = function(id) { + return components[id] && components[id].cmp; + }; + + /** + * Get all components that match the given type + * + * @param {String} type The component type in the form '/' + * + * @returns {*|Array} The components or an empty array + */ + this.getByType = function(type) { + return $.map(components, function(entry) { + return entry.type === type ? entry.cmp : null; + }); + }; + + /** + * Get all components + * + * @returns {*|Array} The components or an empty array + */ + this.getComponents = function() { + return $.map(components, function(entry) { + return entry.cmp; + }); + }; + + /** + * Add the given component to the registry and return the assigned id + * + * @param {*} cmp The component to add + * @param {String} id The optional id that should be assigned to that component + * @param {String} type The component type to load '/' + * + * @returns {*|Array} + */ + this.add = function(cmp, id, type) { + if (!id){ + id = self.getId(cmp) || createId(); + } + components[id] = { + cmp: cmp, + type: type, + active: true + }; + return id; + }; + + /** + * Mark all components inactive + */ + this.markAllInactive = function() { + $.each(components,function(index, el){ + if (el && el.active) { + el.active = false; + } + }); + }; + + /** + * Mark the component with the given id as active + */ + this.markActive = function(id) { + if (components[id]) { + components[id].active = true; + } + }; + + /** + * Let the garbage collection remove all inactive components + */ + this.removeInactive = function() { + $.each(components, function(key,value) { + if (!value || !value.active) { + delete components[key]; + } + }); + }; + }; + return new Registry(); +}); diff --git a/public/js/icinga/util/logging.js b/public/js/icinga/util/logging.js index a3cbc996a..1c523f2ff 100755 --- a/public/js/icinga/util/logging.js +++ b/public/js/icinga/util/logging.js @@ -45,6 +45,10 @@ define(function() { }, error: function() { logTagged('error', arguments); + }, + emergency: function() { + logTagged('emergency', arguments); + // TODO: log *emergency* errors to the backend } }; diff --git a/test/js/test/icinga/componentTest.js b/test/js/test/icinga/componentTest.js new file mode 100644 index 000000000..75f5911bc --- /dev/null +++ b/test/js/test/icinga/componentTest.js @@ -0,0 +1,148 @@ +/** + * {{LICENSE_HEADER}} + * {{LICENSE_HEADER}} + */ + +require('should'); +var rjsmock = require('requiremock.js'); + +GLOBAL.document = $('body'); +var component; + + +/** + * Set up the test fixture + * + * @param registry The optional registry mock that should be used. + */ +var setUp = function(registry) +{ + rjsmock.purgeDependencies(); + + requireNew('icinga/registry.js'); + registry = registry || rjsmock.getDefine(); + + rjsmock.registerDependencies({ + 'icinga/registry': registry, + + /* + * Available components + */ + 'modules/app/component1': function(cmp) { + cmp.test = 'changed-by-component-1'; + this.type = function(){ + return "app/component1"; + }; + }, + 'modules/app/component2': function(cmp) { + cmp.test = 'changed-by-component-2'; + this.type = function(){ + return "app/component2"; + }; + }, + 'modules/module/component3': function(cmp) { + cmp.test = 'changed-by-component-3-from-module'; + this.type = function(){ + return "module/component3"; + }; + } + }); + + $('body').empty(); + + requireNew('icinga/component.js'); + component = rjsmock.getDefine(); +}; + +/** + * Add a new component to the current test-DOM + * + * @param type {String} The type of the component in the form: "/" + * @param id {String} The optional id of the component + */ +var addComponent = function(type,id) { + var txt = '
test
'; + + $('body').append(txt); +}; + +describe('Component loader',function(){ + + it('Component loaded with automatic id',function(){ + setUp(); + addComponent('app/component1'); + + component.load(function(){ + // loading complete + var cmpNode = $('#icinga-component-0'); + cmpNode.length.should.equal(1); + cmpNode[0].test.should.equal('changed-by-component-1'); + component.getById('icinga-component-0').type().should.equal('app/component1'); + }); + }); + + it('Component load with user-defined id',function(){ + setUp(); + addComponent('app/component2','some-id'); + + component.load(function(){ + // loading complete + var cmpNode = $('#some-id'); + cmpNode.length.should.equal(1); + cmpNode[0].test.should.equal('changed-by-component-2'); + component.getById('some-id').type().should.equal('app/component2'); + }); + }); + + it('Garbage collection removes deleted components',function(){ + setUp(); + addComponent('app/component1'); + addComponent('app/component2'); + addComponent('app/component2'); + addComponent('module/component3'); + + component.load(function(){ + // loading complete + var components = component.getComponents(); + components.length.should.equal(4); + $('body').empty(); + component.load(function(){ + var components = component.getComponents(); + components.length.should.equal(0); + }); + }); + }); + + it('Component queries are delegated to the registry correctly',function(){ + var getByIdCalled = false; + var getByTypeCalled = false; + var getComponentsCalled = false; + + var registryMock = { + getById: function(id){ + getByIdCalled = true; + id.should.equal('some-id'); + }, + getByType: function(type){ + getByTypeCalled = true; + type.should.equal('some-type'); + }, + getComponents: function(){ + getComponentsCalled = true; + } + }; + + setUp(registryMock); + + component.getById('some-id'); + getByIdCalled.should.be.true; + + component.getByType('some-type'); + getByTypeCalled.should.be.true; + + component.getComponents(); + getComponentsCalled.should.be.true; + }); +}); + diff --git a/test/js/test/icinga/moduleTest.js b/test/js/test/icinga/moduleTest.js index d525a4d56..35dc29ebe 100644 --- a/test/js/test/icinga/moduleTest.js +++ b/test/js/test/icinga/moduleTest.js @@ -11,6 +11,7 @@ var should = require("should"); var rjsmock = require("requiremock.js"); var asyncMock = require("asyncmock.js"); + requireNew("icinga/module.js"); var module = rjsmock.getDefine(); GLOBAL.document = $('body'); @@ -19,6 +20,7 @@ GLOBAL.document = $('body'); * Test module that only uses eventhandlers and * no custom logic **/ + var eventOnlyModule = function() { var thiz = this; this.moduleLinkClick = false; @@ -82,6 +84,7 @@ var tearDownTestDOM = function() { $('body').empty(); }; + describe('Module loader', function() { var err = null; var errorCallback = function(error) { diff --git a/test/js/test/icinga/registryTest.js b/test/js/test/icinga/registryTest.js new file mode 100644 index 000000000..d66f07077 --- /dev/null +++ b/test/js/test/icinga/registryTest.js @@ -0,0 +1,117 @@ +/** + * {{LICENSE_HEADER}} + * {{LICENSE_HEADER}} + */ + +var should = require('should'); +var rjsmock = require('requiremock.js'); + +GLOBAL.document = $('body'); + +var registry; +var setUp = function() { + requireNew('icinga/registry.js'); + registry = rjsmock.getDefine(); +}; + +var cleanTestDom = function() { + $('body').empty(); +}; + + +describe('Component registry',function(){ + it('Ids are created automatically in the form "icinga-component-"',function(){ + setUp(); + + registry.add({},null,null).should.equal('icinga-component-0'); + registry.add({},null,null).should.equal('icinga-component-1'); + registry.add({},null,null).should.equal('icinga-component-2'); + + cleanTestDom(); + }); + + it('Existing ids are preserved',function(){ + setUp(); + + registry.add({},'user-defined-id',null).should.equal('user-defined-id'); + + cleanTestDom(); + }); + + it('Components are correctly added to the library',function(){ + setUp(); + + var cmp1 = { component: "cmp1" }; + registry.add(cmp1,'user-defined-id',null); + registry.getById('user-defined-id').should.equal(cmp1); + + var cmp2 = { component: "cmp2" }; + registry.add(cmp2,null,null); + registry.getById('icinga-component-0').should.equal(cmp2); + + cleanTestDom(); + }); + + it('getId(component) should return the components assigned id.',function(){ + setUp(); + + var cmp1 = { component: "cmp1" }; + registry.add(cmp1,'user-defined-id',null); + registry.getId(cmp1).should.equal('user-defined-id'); + + var cmp2 = { component: "cmp2" }; + registry.add(cmp2,'user-defined-id-2',null); + registry.getId(cmp2).should.equal('user-defined-id-2'); + + should.not.exist(registry.getId({})); + + cleanTestDom(); + }); + + it('getByType() should return all components of a certain type',function(){ + setUp(); + + var cmp1 = { component: "some/type" }; + registry.add(cmp1,null,'some/type'); + + var cmp2 = { component: "some/type" }; + registry.add(cmp2,null,"some/type"); + + var cmp3 = { component: "other/type" }; + registry.add(cmp3,null,"other/type"); + + var cmps = registry.getByType('some/type'); + cmps.length.should.equal(2); + cmps[0].component.should.equal('some/type'); + cmps[1].component.should.equal('some/type'); + + cleanTestDom(); + }); + + it('getComponents() should return all components',function(){ + setUp(); + + var cmp1 = { component: "cmp1" }; + registry.add(cmp1,null,null); + + var cmp2 = { component: "cmp2" }; + registry.add(cmp2,null,null); + + var cmp3 = { component: "cmp3" }; + registry.add(cmp3,null,null); + + var cmps = registry.getComponents(); + cmps.length.should.equal(3); + cmps[0].should.equal(cmp1); + cmps[1].should.equal(cmp2); + cmps[2].should.equal(cmp3); + + cleanTestDom(); + }); + + /* + * NOTE: The functionality of the garbage collection of this class is + * tested in the componentTest.js + */ +}); + diff --git a/test/js/testlib/requiremock.js b/test/js/testlib/requiremock.js index ab8797aaa..ccebda91f 100644 --- a/test/js/testlib/requiremock.js +++ b/test/js/testlib/requiremock.js @@ -35,6 +35,16 @@ var requireJsMock = function(dependencies, fn) { fn.apply(this,fnArgs); }; +/** + * Mock the Logger + */ +var logger = { + debug: function() {}, + warn: function() {}, + error: function() {}, + emergency: function() {} +}; + /** * Mock for the 'define' function of requireJS, behaves exactly the same * except that it looks up the dependencies in the list provided by registerDepencies() @@ -82,7 +92,7 @@ function initRequireMethods() { GLOBAL.define = defineMock; registeredDependencies = { 'jquery' : GLOBAL.$, - 'logging' : console + 'logging' : logger }; } initRequireMethods(); @@ -94,7 +104,7 @@ initRequireMethods(); function purgeDependencies() { registeredDependencies = { 'jquery' : GLOBAL.$, - 'logging' : console + 'logging' : logger }; } // helper to log debug messages with console