Support angular workflow extension as a feature plugin

This patch does three things:
1) It adds the extensible service which can be used to allow plugins
to customize various containers by adding and removing items such as
workflow steps, table actions, and form fields.

2) It adds support for a "feature" plugin that can be used to add
angular modules that can then be used to customize various panels.
The feature plugin is different from a typical plugin in that it does
not add a dashboard or panel, it only adds angular modules, JS files,
HTML templates, spec files, styles, etc.

3) It updates the workflow service to make each workflow extensible
and adds IDs to the launch instance workflow steps so that this
workflow instance is now extensible.

An example feature plugin is available here:
https://drive.google.com/open?id=0Bye7buoZvOxFOXJvMTNNUTdNNUk

Documentation will be provided by this commit:
  https://review.openstack.org/244407

Implements: blueprint angular-workflow-plugin
Change-Id: I8b426b1644c26b1af063d570da19a75ac8c97c27
This commit is contained in:
Justin 2015-08-10 15:28:13 -05:00 committed by Justin Pomeroy
parent 06ece9406a
commit aa4d3e603d
9 changed files with 553 additions and 16 deletions

View File

@ -0,0 +1,30 @@
/*
* Copyright 2015 IBM Corp.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function () {
'use strict';
/**
* @ngdoc overview
* @name horizon.framework.util.extensible
* @description
*
* # horizon.framework.util.extensible
*
* This module provides a service to allow various UI components to be extended by plugins.
*/
angular
.module('horizon.framework.util.extensible', []);
})();

View File

@ -0,0 +1,247 @@
/*
* Copyright 2015 IBM Corp.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function() {
'use strict';
var $controller;
angular
.module('horizon.framework.util.extensible')
.factory('horizon.framework.util.extensible.service', extensibleService);
extensibleService.$inject = [
'$controller'
];
/**
* @ngdoc service
* @name horizon.framework.util.extensible.service:extensibleService
* @module horizon.framework.util.extensible.service
* @kind function
* @description
*
* Make a container extensible by decorating it with functions that allow items to be added
* or removed.
*
* @returns {Function} A function used to decorate a container to make it extensible.
*/
function extensibleService(_$controller_) {
$controller = _$controller_;
return makeExtensible;
}
/**
* A decorator function that makes the given container object extensible by allowing items to
* be added or removed. This can be used on any object that contains multiple items where a user
* might want to insert or remove their own items. Examples include workflow steps, table
* actions, and form fields. Each item must have a unique ID within the container. It also adds
* the ability to add controllers in the scope of the container. The following functions are
* added to the container:
*
* append(item, priority)
* prepend(item, priority)
* after(id, item, priority)
* remove(id)
* replace(id, item)
* addController(controller)
* initControllers($scope)
*
* Priorities are optional and determine the priority for multiple items placed at the same
* position. Higher numbers mean lower priority. If not provided the item will have the lowest
* priority (infinity).
*
* @param {Object} container - The container object to make extensible.
* @param {Object} items - An array of all items in the container in display order. Each item
* should be an object and must have an id property that uniquely identifies the item within
* the container.
*
* For example, to make a workflow extensible:
*
* extensibleService(workflow, workflow.steps);
*/
function makeExtensible(container, items) {
/**
* Append a new item at the end of the container's items.
*
* @param {Object} item The item to append.
* @param {Number} priority The optional priority for placement at the end of the container.
* Lower priority (higher number) items will be placed at the end but before higher priority
* (lower number) items.
*/
container.append = function(item, priority) {
if (!angular.isNumber(priority)) {
priority = Infinity;
}
var itemsByPosition = getItemsByPosition(items, 'last').reverse();
var index = items.length;
for (var i = 0; i < itemsByPosition.length; i++) {
if (priority > itemsByPosition[i]._ext.priority) {
index = getItemIndex(items, itemsByPosition[i].id);
break;
}
}
item._ext = {position: 'last', priority: priority};
items.splice(index, 0, item);
};
/**
* Add a new item at the beginning of the container's items.
*
* @param {Object} item The item to add at the front.
* @param {Number} priority The optional priority for placement at the front of the container.
* Lower priority (higher number) items will be placed at the front but after higher priority
* (lower number) items.
*/
container.prepend = function(item, priority) {
if (!angular.isNumber(priority)) {
priority = Infinity;
}
var itemsByPosition = getItemsByPosition(items, 'first');
var index = itemsByPosition.length;
for (var i = 0; i < itemsByPosition.length; i++) {
if (priority <= itemsByPosition[i]._ext.priority) {
index = getItemIndex(items, itemsByPosition[i].id);
break;
}
}
item._ext = {position: 'first', priority: priority};
items.splice(index, 0, item);
};
/**
* Add a new item after the item with the given id.
*
* @param {String} id The id of an existing item in the container. The new item will be placed
* after this item.
* @param {Object} item The item to insert.
* @param {Number} priority The optional priority for placement in the container at this
* position. Higher priority (lower number) items will be placed more closely after the
* given item id, followed by lower priority (higher number) items.
*/
container.after = function(id, item, priority) {
if (!angular.isNumber(priority)) {
priority = Infinity;
}
var itemsByPosition = getItemsByPosition(items, 'after-' + id);
var index = getItemIndex(items, id) + itemsByPosition.length + 1;
for (var i = 0; i < itemsByPosition.length; i++) {
if (priority <= itemsByPosition[i]._ext.priority) {
index = getItemIndex(items, itemsByPosition[i].id);
break;
}
}
item._ext = {position: 'after-' + id, priority: priority};
items.splice(index, 0, item);
};
/**
* Remove an item from the container and return its index. When removing items from the
* container you will need to account for any data the item might have been contributing to
* the container's model. A custom controller could be used for this purpose and added using
* the addController function.
*
* @param {String} id The id of the item to remove.
*
* @returns {Number} The index of the item being removed.
*/
container.remove = function(id) {
var index = getItemIndex(items, id);
items.splice(index, 1);
return index;
};
/**
* Replace an item in the container with the one provided. The new item will need to account
* for any data the original item might have been contributing to the container's model.
*
* @param {String} id The id of an existing item in the container. The item with this id will
* be removed and the new item will be inserted in its place.
* @param {Object} item The item to insert.
*/
container.replace = function(id, item) {
var index = container.remove(id);
items.splice(index, 0, item);
};
/**
* The controllers array keeps track of all controllers that should be instantiated with the
* scope of the container.
*/
container.controllers = [];
/**
* When an extensible container is instantiated, it should call this function to initialize
* any additional controllers added by plugins. A typical plugin itself should not need to
* call this, since any extensible containers created in horizon should be doing this.
*/
container.initControllers = function($scope) {
angular.forEach(container.controllers, function(ctrl) {
$controller(ctrl, {$scope: $scope});
});
};
/**
* Add a custom controller to be instantiated with the scope of the container when a container
* instance is created. This is useful in cases where a plugin removes an item or otherwise
* wants to make changes to a container without adding any items. For example, to add some
* custom validation to an existing item or react to certain container events.
*
* @param {String} ctrl The controller to add, e.g. 'MyFeatureController'.
*/
container.addController = function(ctrl) {
container.controllers.push(ctrl);
};
}
/**
* Get an array of items that have been added at a given position.
*
* @param {Array<Object>} items An array of all items in the container.
* @param {String} position The position of the items to return. This can be "first",
* "last", or "after-<id>".
*
* @returns {Array<Object>} An array of items. The returned items are sorted by priority. If
* there are no items for the given position an empty array is returned. If two items have
* the same priority then the last one added will "win". This is so the returned items are
* always in the proper order for display purposes.
*/
function getItemsByPosition(items, position) {
return items.filter(function filterItems(item) {
return item._ext && item._ext.position === position;
}).sort(function sortItems(a, b) {
return (a._ext.priority - b._ext.priority) || 1;
});
}
/**
* Get the index of a given item.
*
* @param {Array<Object>} items An array of all items in the container.
* @param {String} id The id of an item. The index of the item will be returned.
*
* @returns {Number} The index of the item with the given id.
*/
function getItemIndex(items, id) {
for (var i = 0; i < items.length; i++) {
if (items[i].id === id) {
return i;
}
}
throw new Error(interpolate('Item with id %(id)s not found.', {id: id}, true));
}
})();

View File

@ -0,0 +1,192 @@
(function () {
'use strict';
describe('horizon.framework.util.extensible module', function () {
it('should have been defined', function () {
expect(angular.module('horizon.framework.util.extensible')).toBeDefined();
});
});
describe('extensible service', function () {
var extensibleService, container, items;
beforeEach(module('horizon.framework.util.extensible'));
beforeEach(inject(function ($injector) {
extensibleService = $injector.get('horizon.framework.util.extensible.service');
container = {};
items = [ { id: '1' }, { id: '2' }, { id: '3' } ];
extensibleService(container, items);
}));
it('can append items', function () {
expect(items.length).toBe(3);
var item4 = { id: '4' };
container.append(item4, 1);
expect(items.length).toBe(4);
expect(items[3]).toBe(item4);
var item5 = { id: '5' };
container.append(item5);
expect(items.length).toBe(5);
expect(items[3]).toBe(item5);
expect(items[4]).toBe(item4);
var item6 = { id: '6' };
container.append(item6, 2);
expect(items.length).toBe(6);
expect(items[3]).toBe(item5);
expect(items[4]).toBe(item6);
expect(items[5]).toBe(item4);
var item7 = { id: '7' };
container.append(item7, 1);
expect(items.length).toBe(7);
expect(items[3]).toBe(item5);
expect(items[4]).toBe(item6);
expect(items[5]).toBe(item4);
expect(items[6]).toBe(item7);
var item8 = { id: '8' };
container.append(item8);
expect(items.length).toBe(8);
expect(items[3]).toBe(item5);
expect(items[4]).toBe(item8);
expect(items[5]).toBe(item6);
expect(items[6]).toBe(item4);
expect(items[7]).toBe(item7);
});
it('can prepend items', function () {
expect(items.length).toBe(3);
var item4 = { id: '4' };
container.prepend(item4, 1);
expect(items.length).toBe(4);
expect(items[0]).toBe(item4);
var item5 = { id: '5' };
container.prepend(item5);
expect(items.length).toBe(5);
expect(items[0]).toBe(item4);
expect(items[1]).toBe(item5);
var item6 = { id: '6' };
container.prepend(item6, 2);
expect(items.length).toBe(6);
expect(items[0]).toBe(item4);
expect(items[1]).toBe(item6);
expect(items[2]).toBe(item5);
var item7 = { id: '7' };
container.prepend(item7, 1);
expect(items.length).toBe(7);
expect(items[0]).toBe(item7);
expect(items[1]).toBe(item4);
expect(items[2]).toBe(item6);
expect(items[3]).toBe(item5);
var item8 = { id: '8' };
container.prepend(item8);
expect(items.length).toBe(8);
expect(items[0]).toBe(item7);
expect(items[1]).toBe(item4);
expect(items[2]).toBe(item6);
expect(items[3]).toBe(item8);
expect(items[4]).toBe(item5);
});
it('can insert items', function () {
expect(items.length).toBe(3);
var item4 = { id: '4' };
container.after('1', item4, 1);
expect(items.length).toBe(4);
expect(items[1]).toBe(item4);
var item5 = { id: '5' };
container.after('1', item5);
expect(items.length).toBe(5);
expect(items[1]).toBe(item4);
expect(items[2]).toBe(item5);
var item6 = { id: '6' };
container.after('1', item6, 2);
expect(items.length).toBe(6);
expect(items[1]).toBe(item4);
expect(items[2]).toBe(item6);
expect(items[3]).toBe(item5);
var item7 = { id: '7' };
container.after('1', item7, 1);
expect(items.length).toBe(7);
expect(items[1]).toBe(item7);
expect(items[2]).toBe(item4);
expect(items[3]).toBe(item6);
expect(items[4]).toBe(item5);
var item8 = { id: '8' };
container.after('1', item8);
expect(items.length).toBe(8);
expect(items[1]).toBe(item7);
expect(items[2]).toBe(item4);
expect(items[3]).toBe(item6);
expect(items[4]).toBe(item8);
expect(items[5]).toBe(item5);
var last = { id: 'last' };
container.after('3', last);
expect(items.length).toBe(9);
expect(items[8]).toBe(last);
var insert = function() {
container.after('foo', { id: 'bar' });
};
expect(insert).toThrowError(Error, 'Item with id foo not found.');
});
it('can remove items', function () {
expect(items.length).toBe(3);
expect(container.remove('2')).toBe(1);
expect(items.length).toBe(2);
expect(container.remove('1')).toBe(0);
expect(items.length).toBe(1);
var remove = function() {
container.remove('foo');
};
expect(remove).toThrowError(Error, 'Item with id foo not found.');
});
it('can replace items', function () {
expect(items.length).toBe(3);
var item4 = { id: '4' };
container.replace('2', item4);
expect(items.length).toBe(3);
expect(items[1]).toBe(item4);
var item5 = { id: '5' };
container.replace('1', item5);
expect(items.length).toBe(3);
expect(items[0]).toBe(item5);
expect(items[1]).toBe(item4);
expect(items[2].id).toBe('3');
var replace = function() {
container.replace('foo', { id: 'bar' });
};
expect(replace).toThrowError(Error, 'Item with id foo not found.');
});
it('can add controllers', function () {
expect(container.controllers.length).toBe(0);
container.addController('MyController');
expect(container.controllers.length).toBe(1);
});
});
})();

View File

@ -10,7 +10,8 @@
'horizon.framework.util.promise-toggle',
'horizon.framework.util.tech-debt',
'horizon.framework.util.workflow',
'horizon.framework.util.validators'
'horizon.framework.util.validators',
'horizon.framework.util.extensible'
])
.config(config);

View File

@ -1,6 +1,7 @@
/*
* (c) Copyright 2015 Hewlett-Packard Development Company, L.P.
* (c) Copyright 2015 ThoughtWorks, Inc.
* Copyright 2015 IBM Corp.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -21,6 +22,10 @@
.module('horizon.framework.util.workflow')
.factory('horizon.framework.util.workflow.service', workflowService);
workflowService.$inject = [
'horizon.framework.util.extensible.service'
];
/**
* @ngdoc factory
* @name horizon.framework.util.workflow.factory:workflow
@ -87,12 +92,14 @@
}
```
*/
function workflowService() {
return function workflow(spec, decorators) {
angular.forEach(decorators, function service(decorator) {
function workflowService(extensibleService) {
return function createWorkflow(spec, decorators) {
angular.forEach(decorators, function decorate(decorator) {
decorator(spec);
});
extensibleService(spec, spec.steps);
return spec;
};
}
})();

View File

@ -8,7 +8,7 @@
});
describe('workflow factory', function () {
var workflow, spec;
var workflowService, spec;
var decorators = [
function (spec) {
angular.forEach(spec.steps, function (step) {
@ -22,26 +22,26 @@
beforeEach(module('horizon.framework'));
beforeEach(inject(function ($injector) {
workflow = $injector.get('horizon.framework.util.workflow.service');
workflowService = $injector.get('horizon.framework.util.workflow.service');
spec = {
steps: [
{ requireSomeServices: true },
{ },
{ requireSomeServices: true }
{ id: 'base_step_1', requireSomeServices: true },
{ id: 'base_step_2' },
{ id: 'base_step_3', requireSomeServices: true }
]
};
}));
it('workflow is defined', function () {
expect(workflow).toBeDefined();
it('workflowService is defined', function () {
expect(workflowService).toBeDefined();
});
it('workflow is a function', function () {
expect(angular.isFunction(workflow)).toBe(true);
it('workflowService is a function', function () {
expect(angular.isFunction(workflowService)).toBe(true);
});
it('can be decorated', function () {
workflow(spec, decorators);
workflowService(spec, decorators);
var steps = spec.steps;
expect(steps[0].checkReadiness).toBeDefined();
@ -52,5 +52,55 @@
expect(steps[2].checkReadiness).toBeDefined();
expect(angular.isFunction(steps[2].checkReadiness)).toBe(true);
});
it('can be customized', function () {
var workflow = workflowService(spec, decorators);
expect(workflow.steps.length).toBe(3);
expect(workflow.append).toBeDefined();
expect(workflow.prepend).toBeDefined();
expect(workflow.after).toBeDefined();
expect(workflow.replace).toBeDefined();
expect(workflow.remove).toBeDefined();
expect(workflow.controllers).toBeDefined();
expect(workflow.addController).toBeDefined();
expect(workflow.initControllers).toBeDefined();
expect(workflow.controllers.length).toBe(0);
var last = { id: 'last' };
workflow.append(last);
expect(workflow.steps.length).toBe(4);
expect(workflow.steps[3]).toBe(last);
var first = { id: 'first' };
workflow.prepend(first);
expect(workflow.steps.length).toBe(5);
expect(workflow.steps[0]).toBe(first);
expect(workflow.steps[4]).toBe(last);
var after = { id: 'after' };
workflow.after('base_step_2', after);
expect(workflow.steps.length).toBe(6);
expect(workflow.steps[0]).toBe(first);
expect(workflow.steps[3]).toBe(after);
expect(workflow.steps[5]).toBe(last);
var replace = { id: 'replace' };
workflow.replace('base_step_1', replace);
expect(workflow.steps.length).toBe(6);
expect(workflow.steps[0]).toBe(first);
expect(workflow.steps[1]).toBe(replace);
expect(workflow.steps[3]).toBe(after);
expect(workflow.steps[5]).toBe(last);
workflow.remove('base_step_2');
expect(workflow.steps.length).toBe(5);
expect(workflow.steps[0]).toBe(first);
expect(workflow.steps[1]).toBe(replace);
expect(workflow.steps[2]).toBe(after);
expect(workflow.steps[4]).toBe(last);
workflow.addController('MyController');
expect(workflow.controllers.length).toBe(1);
});
});
})();

View File

@ -44,6 +44,9 @@
$scope.initPromise = initTask.promise;
$scope.currentIndex = -1;
$scope.workflow = $scope.workflow || {};
if ($scope.workflow.initControllers) {
$scope.workflow.initControllers($scope);
}
var steps = $scope.steps = $scope.workflow.steps || [];
$scope.wizardForm = {};

View File

@ -31,18 +31,21 @@
steps: [
{
id: 'source',
title: gettext('Select Source'),
templateUrl: basePath + 'source/source.html',
helpUrl: basePath + 'source/source.help.html',
formName: 'launchInstanceSourceForm'
},
{
id: 'flavor',
title: gettext('Flavor'),
templateUrl: basePath + 'flavor/flavor.html',
helpUrl: basePath + 'flavor/flavor.help.html',
formName: 'launchInstanceFlavorForm'
},
{
id: 'networks',
title: gettext('Networks'),
templateUrl: basePath + 'network/network.html',
helpUrl: basePath + 'network/network.help.html',
@ -50,18 +53,21 @@
requiredServiceTypes: ['network']
},
{
id: 'secgroups',
title: gettext('Security Groups'),
templateUrl: basePath + 'security-groups/security-groups.html',
helpUrl: basePath + 'security-groups/security-groups.help.html',
formName: 'launchInstanceAccessAndSecurityForm'
},
{
id: 'keypair',
title: gettext('Key Pair'),
templateUrl: basePath + 'keypair/keypair.html',
helpUrl: basePath + 'keypair/keypair.help.html',
formName: 'launchInstanceKeypairForm'
},
{
id: 'configuration',
title: gettext('Configuration'),
templateUrl: basePath + 'configuration/configuration.html',
helpUrl: basePath + 'configuration/configuration.help.html',

View File

@ -47,11 +47,12 @@ def import_dashboard_config(modules):
dashboard = submodule.DASHBOARD
config[dashboard].update(submodule.__dict__)
elif (hasattr(submodule, 'PANEL')
or hasattr(submodule, 'PANEL_GROUP')):
or hasattr(submodule, 'PANEL_GROUP')
or hasattr(submodule, 'FEATURE')):
config[submodule.__name__] = submodule.__dict__
else:
logging.warning("Skipping %s because it doesn't have DASHBOARD"
", PANEL or PANEL_GROUP defined.",
", PANEL, PANEL_GROUP, or FEATURE defined.",
submodule.__name__)
return sorted(six.iteritems(config),
key=lambda c: c[1]['__name__'].rsplit('.', 1))