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
This commit is contained in:
Matthias Jentsch 2013-08-20 12:00:41 +02:00 committed by Marius Hein
parent 4cdab8d904
commit 52c66893ab
10 changed files with 590 additions and 23 deletions

View File

@ -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;

View File

@ -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 '<module>/<component>'
* @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 '<module>/<component>'
*
* @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();
});

View File

@ -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('<span class="add-on">' +
'<i data-time-icon="icon-time" data-date-icon="icon-calendar"></i></span>')
.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('<span class="add-on">' +
'<i data-time-icon="icon-time" data-date-icon="icon-calendar"></i></span>')
.datetimepicker();
};
return new DateTimeBehaviour();
return DateTimePicker;
});

View File

@ -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);
},

View File

@ -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 '<module>/<component>'
*
* @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 '<module>/<component>'
*
* @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();
});

View File

@ -45,6 +45,10 @@ define(function() {
},
error: function() {
logTagged('error', arguments);
},
emergency: function() {
logTagged('emergency', arguments);
// TODO: log *emergency* errors to the backend
}
};

View File

@ -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: "<module>/<type>"
* @param id {String} The optional id of the component
*/
var addComponent = function(type,id) {
var txt = '<div '+( id ? ( ' id= "'+id+'" ' ) : '' ) +
' data-icinga-component="'+type+'" >test</div>';
$('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;
});
});

View File

@ -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) {

View File

@ -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-<id>"',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
*/
});

View File

@ -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