diff --git a/horizon/static/framework/util/extensible/extensible.module.js b/horizon/static/framework/util/extensible/extensible.module.js new file mode 100644 index 0000000000..0a1ae5e145 --- /dev/null +++ b/horizon/static/framework/util/extensible/extensible.module.js @@ -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', []); +})(); diff --git a/horizon/static/framework/util/extensible/extensible.service.js b/horizon/static/framework/util/extensible/extensible.service.js new file mode 100644 index 0000000000..d9e7046464 --- /dev/null +++ b/horizon/static/framework/util/extensible/extensible.service.js @@ -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} 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-". + * + * @returns {Array} 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} 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)); + } + +})(); diff --git a/horizon/static/framework/util/extensible/extensible.spec.js b/horizon/static/framework/util/extensible/extensible.spec.js new file mode 100644 index 0000000000..b6c315c809 --- /dev/null +++ b/horizon/static/framework/util/extensible/extensible.spec.js @@ -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); + }); + }); + +})(); diff --git a/horizon/static/framework/util/util.module.js b/horizon/static/framework/util/util.module.js index 0aa1027870..30ff44e4ec 100644 --- a/horizon/static/framework/util/util.module.js +++ b/horizon/static/framework/util/util.module.js @@ -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); diff --git a/horizon/static/framework/util/workflow/workflow.service.js b/horizon/static/framework/util/workflow/workflow.service.js index fd573387fc..f9061256ac 100644 --- a/horizon/static/framework/util/workflow/workflow.service.js +++ b/horizon/static/framework/util/workflow/workflow.service.js @@ -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; }; } + })(); diff --git a/horizon/static/framework/util/workflow/workflow.spec.js b/horizon/static/framework/util/workflow/workflow.spec.js index f693a61225..51ff588af7 100644 --- a/horizon/static/framework/util/workflow/workflow.spec.js +++ b/horizon/static/framework/util/workflow/workflow.spec.js @@ -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); + }); }); })(); diff --git a/horizon/static/framework/widgets/wizard/wizard.controller.js b/horizon/static/framework/widgets/wizard/wizard.controller.js index 3474ad3183..972d91326b 100644 --- a/horizon/static/framework/widgets/wizard/wizard.controller.js +++ b/horizon/static/framework/widgets/wizard/wizard.controller.js @@ -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 = {}; diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.js b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.js index 926136f8ba..7f6e19969e 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/launch-instance-workflow.service.js @@ -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', diff --git a/openstack_dashboard/utils/settings.py b/openstack_dashboard/utils/settings.py index f0b3e02846..227334d334 100644 --- a/openstack_dashboard/utils/settings.py +++ b/openstack_dashboard/utils/settings.py @@ -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))