Commit e4735cdd authored by Tobias Kaupat's avatar Tobias Kaupat Committed by Tobias Kaupat

Introduced root "Dashboard" class that contains the plugins

- Fix some Webpack compilation issues during tests (no more babel)
- Add Tests for Datasource Plugins
- Renamed dashboard reducer to global to not get confused with new "Dashboard" class
parent f0ef01b4
......@@ -18,6 +18,7 @@
"lint": "gulp lint",
"webpack-profile": "webpack --profile --json --config webpack.client.js > stats.json",
"tsc": "tsc",
"tsc:watch": "tsc --watch",
"gulp": "gulp",
"typings": "typings",
"mocha": "mocha"
......@@ -100,6 +101,7 @@
"semantic-ui-css": "2.1.8",
"sinon": "^1.17.4",
"source-map-loader": "^0.1.5",
"source-map-support": "^0.4.2",
"style-loader": "0.13.1",
"ts-jsx-loader": "0.2.1",
"ts-loader": "0.8.2",
......
......@@ -22,11 +22,14 @@ import * as RandomDatasource from './datasource/plugins/randomDatasource.js'
import * as TimeDatasource from './datasource/plugins/timeDatasource.js'
import * as Store from './store'
import * as Plugins from './pluginApi/plugins.js'
import * as Persist from "./persistence.js";
import * as Persist from "./persistence.js"
import Dashboard from './dashboard'
const initialState = Persist.loadFromLocalStorage();
const dashboardStore = Store.create(initialState);
Store.setGlobalStore(dashboardStore);
const dashboard = new Dashboard(dashboardStore);
dashboard.init();
function loadInitialPlugins(store: Store.DashboardStore) {
store.dispatch(Plugins.loadPlugin(TextWidget));
......@@ -65,5 +68,5 @@ else {
function renderDashboard(element: Element, store: Store.DashboardStore) {
Renderer.render(element, store);
DatasourceWorker.start();
}
DatasourceWorker.start(store);
}
\ No newline at end of file
......@@ -26,10 +26,10 @@ export interface State {
export interface ITypeInfo {
type: string // The name of the type - must be unique
name: string // The user friendly name of the Plugin
description: string // A user friendly description that explains the Plugin
dependencies: string[] // A list of URL's to load external scripts from. Some scripts like jQuery will be available by default in future
settings: ISetting[] // A list of settings that can be changed by the user when the Plugin is initialized
name?: string // The user friendly name of the Plugin
description?: string // A user friendly description that explains the Plugin
dependencies?: string[] // A list of URL's to load external scripts from. Some scripts like jQuery will be available by default in future
settings?: ISetting[] // A list of settings that can be changed by the user when the Plugin is initialized
}
export interface ISetting {
......
......@@ -2,9 +2,13 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import 'source-map-support'
import "file?name=[name].[ext]!./tests.html"
const win = (<any>window);
win.sourceMapSupport.install();
/* inject:tests */
import './datasource/datasource.test.ts'
import './datasource/datasourcePlugins.test.ts'
......@@ -12,15 +16,15 @@ import './datasource/plugins/randomDatasource.test.js'
import './pluginApi/uri.test.js'
import './serverRenderer.test.ts'
import './util/collection.test.js'
import './widgets/widgetPlugins.test.js'
import './widgets/widgetPlugins.test.ts'
import './widgets/widgets.test.ts'
/* endinject */
// In case we run with phantomJS this is needed
// Waiting for https://github.com/webpack/mocha-loader/pull/27
if (typeof window !== 'undefined' && window.initMochaPhantomJS) {
if (typeof window !== 'undefined' && win.initMochaPhantomJS) {
console.log("calling `window.initMochaPhantomJS()`");
window.initMochaPhantomJS();
win.initMochaPhantomJS();
}
else {
console.log("no window found!");
......
import {DashboardStore} from "./store";
import {WidgetPluginRegistry} from "./widgets/widgetPlugins.js";
import {DatasourcePluginRegistry} from "./datasource/datasourcePlugins";
import * as Plugins from "./pluginApi/plugins.js";
/**
* The root of the Dashboard business Logic
* Defines the lifecycle of the Dashboard from creation till disposal
*/
export default class Dashboard {
private static _instance: Dashboard;
private _datasourcePluginRegistry: DatasourcePluginRegistry;
private _widgetPluginRegistry: WidgetPluginRegistry;
constructor(private _store: DashboardStore) {
this._datasourcePluginRegistry = new DatasourcePluginRegistry(_store);
this._widgetPluginRegistry = new WidgetPluginRegistry(_store);
}
get datasourcePluginRegistry() {
return this._datasourcePluginRegistry;
}
get widgetPluginRegistry() {
return this._widgetPluginRegistry;
}
public init() {
Dashboard.setInstance(this);
this._store.dispatch(Plugins.initializeExternalPlugins());
}
static setInstance(dashboard: Dashboard) {
Dashboard._instance = dashboard;
}
/**
* We have some code that depends on this global instance of the Dashboard
* This is bad, but better that static references
* we have at least the chance to influence the instance during tests
*
* @returns {Dashboard}
*/
static getInstance() {
if (!Dashboard._instance) {
throw new Error("No global dashboard created. Call setInstance(dashboard) before!");
}
return Dashboard._instance;
}
}
\ No newline at end of file
......@@ -24,7 +24,7 @@ function setReadOnlyAction(isReadOnly) {
}
export function dashboard(state = initialState, action) {
export function global(state = initialState, action) {
switch (action.type) {
case Action.SET_READONLY:
return Object.assign({}, state, {
......
......@@ -6,8 +6,9 @@ import {assert} from "chai";
import * as Datasource from "./datasource";
import * as Store from "../store";
import * as Plugins from "../pluginApi/plugins.js";
import Dashboard from "../dashboard";
describe("Datasource", function () {
describe("Datasource > Datasource", function () {
describe("api", function () {
/**
* For the Datasource API we have to consider different use cases how a Datasource wants to provide data:
......@@ -25,6 +26,7 @@ describe("Datasource", function () {
- dispose() is called when the datasource is unloaded
- fetchData() is called as configured in the settings
-- fetchData(dataStore) can return a promise or a value - must be an array
- Error when loading plugin twice
*/
it("datasource must implement getValues()", function () {
......@@ -36,6 +38,9 @@ describe("Datasource", function () {
};
const store = Store.createEmpty({log: true});
const dashboard = new Dashboard(store);
dashboard.init();
store.dispatch(Plugins.loadPlugin(DatasourcePlugin));
try {
store.dispatch(Datasource.createDatasource("test-ds", {}, "ds-id"));
......@@ -46,7 +51,35 @@ describe("Datasource", function () {
}
});
it("fetchData() is called as configured in the settings", function () {
return;
const DatasourcePlugin = {
TYPE_INFO: {
type: "test-ds",
fetchData: {
interval: 1000
}
},
Datasource: function (props: any) {
this.getValues = function():any[] {
return [];
};
this.fetchData = (resolve: ResolveFunc<any[]>) => {
resolve([1, 2, 3]);
};
}
};
// TODO: new store but old state outside of the store :(
const store = Store.createEmpty({log: true});
const dashboard = new Dashboard(store);
dashboard.init();
store.dispatch(Plugins.loadPlugin(DatasourcePlugin));
store.dispatch(Datasource.createDatasource("test-ds", {}, "ds-id"));
})
});
});
\ No newline at end of file
});
interface ResolveFunc<T> {
(value?: T | Thenable<T>): void
}
\ No newline at end of file
......@@ -10,6 +10,7 @@ import * as _ from 'lodash'
import * as ModalIds from '../modal/modalDialogIds.js'
import * as Modal from '../modal/modalDialog.js'
import * as AppState from "../appState";
import Dashboard from "../dashboard";
const initialDatasources: IDatasourcesState = {
"initial_random_source": {
......@@ -79,11 +80,8 @@ export function addDatasource(dsType: string, settings: any, id: string = Uuid.g
settings
});
const dsFactory = DatasourcePlugins.pluginRegistry.getPlugin(dsType);
const dsFactory = Dashboard.getInstance().datasourcePluginRegistry.getPlugin(dsType);
dsFactory.createInstance(id);
//const state = getState();
//DatasourceWorker.initializeWorkers(state.datasources, dispatch);
}
}
......@@ -129,7 +127,7 @@ export function fetchDatasourceData(): AppState.ThunkAction {
const dsStates = state.datasources;
_.valuesIn<IDatasourceState>(dsStates).forEach(dsState => {
const dsFactory = DatasourcePlugins.pluginRegistry.getPlugin(dsState.type);
const dsFactory = Dashboard.getInstance().datasourcePluginRegistry.getPlugin(dsState.type);
if (dsFactory === undefined) {
console.warn("Can not fetch data from non existent datasource plugin of type ", dsState.type);
......
......@@ -2,58 +2,75 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import * as _ from 'lodash'
import * as _ from "lodash";
import {DashboardStore} from "../store";
import {IPluginFactory, IPlugin} from "../pluginApi/pluginRegistry";
import {IDatasourceState} from "./datasource";
import Unsubscribe = Redux.Unsubscribe;
export interface IDatasourcePlugin extends IPlugin {
new(props: any): IDatasourcePlugin
props: any
datasourceWillReceiveProps?: (newProps: any) => void
dispose?: () => void
getValues(): any[] // TODO: Might get depricated
}
/**
* Connects a datasource to the application state
*/
// TODO: Rename to ...Factory
export class DataSourcePlugin {
constructor(module, store) {
console.assert(module.TYPE_INFO, "Missing TYPE_INFO on datasource module. Every module must export TYPE_INFO");
this._type = module.TYPE_INFO.type;
this.Datasource = module.Datasource;
export default class DataSourcePluginFactory implements IPluginFactory<IDatasourcePlugin> {
this.store = store;
private _instances: {[id: string]: IDatasourcePlugin} = {};
private _unsubscribe: Unsubscribe;
private _disposed: boolean = false;
this.instances = {};
this.unsubscribe = store.subscribe(this.handleStateChange.bind(this));
this.disposed = false;
// TODO: type datasource plugin constructor?
constructor(private _type: string, private _datasource: IDatasourcePlugin, private _store: DashboardStore) {
this._unsubscribe = _store.subscribe(this.handleStateChange.bind(this));
}
get type() {
return this._type;
}
getDatasourceState(id) {
const state = this.store.getState();
get disposed() {
return this._disposed;
}
getDatasourceState(id: string) {
const state = this._store.getState();
return state.datasources[id];
}
/**
* Better use getInstance or createInstance directly!
*/
getOrCreateInstance(id) {
if (!this.instances[id]) {
getOrCreateInstance(id: string) {
if (!this._instances[id]) {
return this.createInstance(id)
}
return this.getInstance(id);
}
createInstance(id) {
if (this.disposed === true) {
createInstance(id: string): IDatasourcePlugin {
if (this._disposed === true) {
throw new Error("Try to create datasource of destroyed type: " + JSON.stringify({id, type: this.type}));
}
if (this.instances[id] !== undefined) {
throw new Error("Can not create datasource instance. It already exists: " + JSON.stringify({id, type: this.type}));
if (this._instances[id] !== undefined) {
throw new Error("Can not create datasource instance. It already exists: " + JSON.stringify({
id,
type: this.type
}));
}
const dsState = this.getDatasourceState(id);
const props = {
state: dsState
};
const instance = new this.Datasource(props);
const instance = new this._datasource(props);
instance.props = props;
// Bind API functions to instance
......@@ -66,26 +83,29 @@ export class DataSourcePlugin {
if (_.isFunction(instance.getValues)) {
instance.getValues = instance.getValues.bind(instance);
} else {
throw new Error('Datasource must implement "getValues(): any[]" but is missing. ' + JSON.stringify({id, type: this.type}));
throw new Error('Datasource must implement "getValues(): any[]" but is missing. ' + JSON.stringify({
id,
type: this.type
}));
}
this.instances[id] = instance;
this._instances[id] = instance;
return instance;
}
getInstance(id) {
if (this.disposed === true) {
getInstance(id: string) {
if (this._disposed === true) {
throw new Error("Try to get datasource of destroyed type. " + JSON.stringify({id, type: this.type}));
}
if (!this.instances[id]) {
if (!this._instances[id]) {
throw new Error("No running instance of datasource. " + JSON.stringify({id, type: this.type}));
}
return this.instances[id];
return this._instances[id];
}
dispose() {
this.disposed = true;
_.valuesIn(this.instances).forEach((instance) => {
this._disposed = true;
_.valuesIn<IDatasourcePlugin>(this._instances).forEach((instance) => {
if (_.isFunction(instance.dispose)) {
try {
instance.dispose();
......@@ -95,17 +115,17 @@ export class DataSourcePlugin {
}
}
});
this.instances = [];
this.unsubscribe();
this._instances = {};
this._unsubscribe();
}
handleStateChange() {
const state = this.store.getState();
_.valuesIn(state.datasources).forEach(dsState => this.updateDatasource(dsState))
const state = this._store.getState();
_.valuesIn<IDatasourceState>(state.datasources).forEach(dsState => this.updateDatasource(dsState))
}
updateDatasource(dsState) {
const instance = this.instances[dsState.id];
updateDatasource(dsState: IDatasourceState) {
const instance = this._instances[dsState.id];
if (!instance) {
// This is normal to happen when the app starts,
// since the state already contains the id's before plugin instances are loaded
......
......@@ -2,13 +2,18 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import {assert} from 'chai'
import * as DatasourcePlugins from './datasourcePlugins'
import * as Store from '../store'
import * as AppState from '../appState'
import * as Sinon from 'sinon'
import scriptloader from '../util/scriptLoader';
import * as pluginCache from '../pluginApi/pluginCache.js'
import {assert} from "chai";
import * as DatasourcePlugins from "./datasourcePlugins";
import {DatasourcePluginRegistry} from "./datasourcePlugins";
import * as Store from "../store";
import * as AppState from "../appState";
import * as Sinon from "sinon";
import scriptloader from "../util/scriptLoader";
import * as pluginCache from "../pluginApi/pluginCache.js";
import {default as DataSourcePluginFactory} from "./datasourcePluginFactory";
import Dashboard from "../dashboard";
import SinonStubStatic = Sinon.SinonStubStatic;
import SinonStub = Sinon.SinonStub;
const stateWithExternalDatasource: AppState.State = Store.emptyState();
stateWithExternalDatasource.datasourcePlugins = {
......@@ -22,7 +27,7 @@ stateWithExternalDatasource.datasourcePlugins = {
};
// TODO: Test Actions, Test Reducer
describe('Datasource Plugins', function () {
describe('Datasource > DatasourcePlugins', function () {
describe("plugin registration", function () {
/* TODO: Testcases
......@@ -36,7 +41,19 @@ describe('Datasource Plugins', function () {
-- load from url fails
- register internal plugin
*/
it("a external plugin is loaded when it is already in state", function () {
let loadScriptStub: SinonStub;
beforeEach(function () {
loadScriptStub = Sinon.stub(scriptloader, "loadScript");
});
afterEach(function () {
loadScriptStub.restore()
});
it("an external plugin is loaded when it is already in state", function () {
// TYPE_INFO and Datasource is usually created inside the plugin script
const TYPE_INFO = {type: "ext-ds"};
......@@ -44,8 +61,9 @@ describe('Datasource Plugins', function () {
return;
};
// TODO: the test fails on webpack hot reaload sometimes ...
const loadScriptStub = Sinon.stub(scriptloader, "loadScript", function (scripts: string[], options: any) {
// Restore
loadScriptStub.restore();
loadScriptStub = Sinon.stub(scriptloader, "loadScript", function (scripts: string[], options: any) {
pluginCache.registerDatasourcePlugin(TYPE_INFO, Datasource);
// In reality the success function is called async
......@@ -56,15 +74,19 @@ describe('Datasource Plugins', function () {
const store = Store.create(stateWithExternalDatasource, {log: false});
const state = store.getState();
const plugin = DatasourcePlugins.pluginRegistry.getPlugin("ext-ds");
const dashboard = new Dashboard(store);
dashboard.init();
const plugin: DataSourcePluginFactory = dashboard.datasourcePluginRegistry.getPlugin("ext-ds");
assert.isOk(loadScriptStub.calledOnce);
assert.isOk(plugin, "The loaded plugin is okay");
assert.equal(plugin.disposed, false, "The loaded plugin is not disposed");
assert.deepEqual(plugin.instances, {}, "The loaded plugin has no instances");
assert.equal(plugin.store, store, "The loaded plugin knows the correct store");
assert.equal(plugin.Datasource, Datasource, "The loaded plugin knows the datasouces");
assert.equal(plugin._type, "ext-ds", "The loaded plugin knows the plugin type");
assert.deepEqual((<any>plugin)._instances, {}, "The loaded plugin has no instances");
assert.equal((<any>plugin)._store, store, "The loaded plugin knows the correct store");
assert.equal((<any>plugin)._datasource, Datasource, "The loaded plugin knows the datasouces");
assert.equal(plugin.type, "ext-ds", "The loaded plugin knows the plugin type");
assert.deepEqual(state.widgets, {}, "The new state has no widgets");
assert.deepEqual(state.datasources, {}, "The new state has no datasources");
......@@ -85,14 +107,18 @@ describe('Datasource Plugins', function () {
describe('pluginRegistry #register() && #getPlugin()', function () {
it("It's possible to register and get back a plugin", function () {
DatasourcePlugins.pluginRegistry.store = Store.create();
DatasourcePlugins.pluginRegistry.register({
const registry = new DatasourcePluginRegistry(Store.create());
registry.register({
TYPE_INFO: {
type: 'foo'
},
Datasource: function (props: any) {
return;
}
});
const plugin = DatasourcePlugins.pluginRegistry.getPlugin('foo');
const plugin = registry.getPlugin('foo');
assert.isOk(plugin);
assert.equal('foo', plugin.type);
......
......@@ -2,15 +2,21 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import * as DsPlugin from './datasourcePlugin.js'
import PluginRegistry from '../pluginApi/pluginRegistry.js'
import * as Action from '../actionNames';
import {genCrudReducer} from '../util/reducer.js';
import * as AppState from '../appState'
import PluginRegistry, {IPluginModule, IPluginFactory} from "../pluginApi/pluginRegistry";
import * as Action from "../actionNames";
import {genCrudReducer} from "../util/reducer.js";
import * as AppState from "../appState";
import DataSourcePluginFactory, {IDatasourcePlugin} from "./datasourcePluginFactory";
import Dashboard from "../dashboard";
const initialState: IDatasourcePluginsState = {};
interface IPluginModule {
interface IDatasourcePluginModule extends IPluginModule {
Datasource: any
}
interface IDatasourcePluginFactory extends IPluginFactory<IDatasourcePlugin> {
}
......@@ -35,19 +41,19 @@ export interface IDatasourcePluginAction extends AppState.Action {
}
export class DatasourcePluginRegistry extends PluginRegistry {
export class DatasourcePluginRegistry extends PluginRegistry<IDatasourcePlugin, IDatasourcePluginModule, DataSourcePluginFactory> {
createPluginFromModule(module: IPluginModule) {
return new DsPlugin.DataSourcePlugin(module, this.store);
createPluginFromModule(module: IDatasourcePluginModule) {
console.assert(_.isObject(module.TYPE_INFO), "Missing TYPE_INFO on datasource module. Every module must export TYPE_INFO");
return new DataSourcePluginFactory(module.TYPE_INFO.type, module.Datasource, this.store);
}
}
export const pluginRegistry = new DatasourcePluginRegistry();
export function unloadPlugin(type: string) {
return function (dispatch: AppState.Dispatch) {
const dsFactory = pluginRegistry.getPlugin(type);
const dsFactory = Dashboard.getInstance().datasourcePluginRegistry.getPlugin(type);
dsFactory.dispose();
dispatch(deletePlugin(type));
}
......
......@@ -8,12 +8,12 @@ import * as Store from '../store'
let heartbeat;
export function start() {