diff --git a/horizon/static/horizon/js/angular/services/hz.api.nova.js b/horizon/static/horizon/js/angular/services/hz.api.nova.js index 3e5ba0247d..66ef958037 100644 --- a/horizon/static/horizon/js/angular/services/hz.api.nova.js +++ b/horizon/static/horizon/js/angular/services/hz.api.nova.js @@ -270,40 +270,55 @@ limitations under the License. angular.module('hz.api') .service('novaAPI', ['apiService', NovaAPI]); - /** - * @ngdoc service - * @name hz.api.novaExtensions - * @description - * Provides cached access to Nova Extensions with utilities to help - * with asynchronous data loading. The cache may be reset at any time - * by accessing the cache and calling removeAll. The next call to any - * function will retrieve fresh results. - * - * The enabled extensions do not change often, so using cached data will - * speed up results. Even on a local devstack in informal testing, - * this saved between 30 - 100 ms per request. - */ + /** + * @ngdoc service + * @name hz.api.novaExtensions + * @description + * Provides cached access to Nova Extensions with utilities to help + * with asynchronous data loading. The cache may be reset at any time + * by accessing the cache and calling removeAll. The next call to any + * function will retrieve fresh results. + * + * The enabled extensions do not change often, so using cached data will + * speed up results. Even on a local devstack in informal testing, + * this saved between 30 - 100 ms per request. + */ function NovaExtensions($cacheFactory, $q, novaAPI) { + var service = {}; + service.cache = $cacheFactory('hz.api.novaExtensions', {capacity: 1}); - var service = {}; - service.cache = $cacheFactory('hz.api.novaExtensions', {capacity: 1}); + service.get = function () { + return novaAPI.getExtensions({cache: service.cache}) + .then(function (data) { + return data.data.items; + }); + }; - service.get = function() { - return novaAPI.getExtensions({cache: service.cache}) - .then(function(data){ - return data.data.items; + service.ifNameEnabled = function(desired) { + var deferred = $q.defer(); + + service.get().then(onDataLoaded, onDataFailure); + + function onDataLoaded(extensions) { + if (enabled(extensions, 'name', desired)) { + deferred.resolve(); + } else { + deferred.reject(interpolate( + gettext('Extension is not enabled: %(extension)s'), + {extension: desired}, + true)); } - ); + } + + function onDataFailure() { + deferred.reject(gettext('Cannot get nova extension list.')); + } + + return deferred.promise; }; - service.ifNameEnabled = function(desired, doThis) { - return service.get().then(function(extensions){ - if (enabled(extensions, 'name', desired)){ - return $q.when(doThis()); - } - } - ); - }; + // This is an alias to support the extension directive default interface + service.ifEnabled = service.ifNameEnabled; function enabled(resources, key, desired) { if(resources) { @@ -315,7 +330,7 @@ limitations under the License. } } - return service; + return service; } angular.module('hz.api') diff --git a/openstack_dashboard/enabled/_10_project.py b/openstack_dashboard/enabled/_10_project.py index 3111c4b1aa..532d14e313 100644 --- a/openstack_dashboard/enabled/_10_project.py +++ b/openstack_dashboard/enabled/_10_project.py @@ -28,6 +28,7 @@ LAUNCH_INST = 'dashboard/launch-instance/' ADD_JS_FILES = [ 'dashboard/dashboard.module.js', 'dashboard/workflow/workflow.js', + 'dashboard/cloud-services/cloud-services.js', LAUNCH_INST + 'launch-instance.js', LAUNCH_INST + 'launch-instance.model.js', LAUNCH_INST + 'source/source.js', @@ -43,6 +44,7 @@ ADD_JS_FILES = [ ADD_JS_SPEC_FILES = [ 'dashboard/dashboard.module.spec.js', 'dashboard/workflow/workflow.spec.js', + 'dashboard/cloud-services/cloud-services.spec.js', LAUNCH_INST + 'launch-instance.spec.js', LAUNCH_INST + 'launch-instance.model.spec.js', LAUNCH_INST + 'source/source.spec.js', diff --git a/openstack_dashboard/static/dashboard/cloud-services/cloud-services.js b/openstack_dashboard/static/dashboard/cloud-services/cloud-services.js new file mode 100644 index 0000000000..0a3bea86ef --- /dev/null +++ b/openstack_dashboard/static/dashboard/cloud-services/cloud-services.js @@ -0,0 +1,193 @@ +/* + * (c) Copyright 2015 Hewlett-Packard Development Company, L.P. + * + * 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 fromJson = angular.fromJson, + isArray = angular.isArray; + + angular.module('hz.dashboard') + + /** + * @ngdoc factory + * @name hz.dashboard:factory:cloudServices + * @module hz.dashboard + * @kind hash table + * @description + * + * Provides a hash table contains all the cloud services so that: + * + * 1) Easy to inject all the services since they are injected with one dependency. + * 2) Provides a way to look up a service by name programmatically. + * + * The use of this is currently limited to existing API services. Use at + * your own risk for extensibility purposes at this time. The API will + * be evolving in the coming release and backward compatibility is not + * guaranteed. This also makes no guarantee that the back-end service + * is actually enabled. + */ + + .factory('cloudServices', [ + 'cinderAPI', + 'glanceAPI', + 'keystoneAPI', + 'neutronAPI', + 'novaAPI', + 'novaExtensions', + 'securityGroup', + 'serviceCatalog', + + function (cinderAPI, + glanceAPI, + keystoneAPI, + neutronAPI, + novaAPI, + novaExtensions, + securityGroup, + serviceCatalog) { + + return { + cinder: cinderAPI, + glance: glanceAPI, + keystone: keystoneAPI, + neutron: neutronAPI, + nova: novaAPI, + novaExtensions: novaExtensions, + securityGroup: securityGroup, + serviceCatalog: serviceCatalog + }; + } + ]) + + /** + * @ngdoc factory + * @name hz.dashboard:factory:ifExtensionsEnabled + * @module hz.dashboard + * @kind function + * @description + * + * Check to see if all the listed extensions are enabled on a certain service, + * which is described by the service name. + * + * This is an asynchronous operation. + * + * @param String serviceName The name of the service, e.g. `novaExtensions`. + * @param Array extensions A list of extension's names. + * @return Promise the promise of the deferred task that gets resolved + * when all the sub-tasks are resolved. + */ + + .factory('ifExtensionsEnabled', ['$q', 'cloudServices', + function ($q, cloudServices) { + return function ifExtensionsEnabled(serviceName, extensions) { + return $q.all( + extensions.map(function (extension) { + return cloudServices[serviceName].ifEnabled(extension); + }) + );//return + };//return + } + ]) + + /** + * @ngdoc factory + * @name hz.dashboard:factory:createDirectiveSpec + * @module hz.dashboard + * @kind function + * @description + * + * A normalized function that can create a directive specification object + * based on `serviceName`. + * + * @param String serviceName The name of the service, e.g. `novaExtensions`. + * @return Object a directive specification object that can be used to + * create an angular directive. + */ + + .factory('createDirectiveSpec', ['ifExtensionsEnabled', + function (ifExtensionsEnabled) { + return function createDirectiveSpec(serviceName) { + return { + restrict: 'E', + transclude: true, + link: function link(scope, element, attrs, ctrl, transclude) { + element.addClass('ng-hide'); + var extensions = fromJson(attrs.requiredExtensions); + if (isArray(extensions)) { + ifExtensionsEnabled(serviceName, extensions).then( + function () { + element.removeClass('ng-hide'); + }, + function () { + element.remove(); + } + );//if-then + } + transclude(scope, function (clone) { + element.append(clone); + }); + }//link + };//return + };//return + } + ]) + + /** + * @ngdoc directive + * @name hz.dashboard:directive:novaExtension + * @module hz.dashboard + * @description + * + * This is to enable specifying conditional UI in a declarative way. + * Some UI components should be showing only when some certain extensions + * are enabled on `novaExtensions` service. + * + * @example + * + ```html + +
+ +
+
+ + +
+ + +
+
+ ``` + */ + + .directive('novaExtension', ['createDirectiveSpec', + function (createDirectiveSpec) { + return createDirectiveSpec('novaExtensions'); + } + ]) + +;})(); diff --git a/openstack_dashboard/static/dashboard/cloud-services/cloud-services.spec.js b/openstack_dashboard/static/dashboard/cloud-services/cloud-services.spec.js new file mode 100644 index 0000000000..858ae810d4 --- /dev/null +++ b/openstack_dashboard/static/dashboard/cloud-services/cloud-services.spec.js @@ -0,0 +1,257 @@ +/* + * (c) Copyright 2015 Hewlett-Packard Development Company, L.P. + * + * 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'; + + describe('hz.dashboard', function () { + + // + // factory:cloudServices + // + + describe('factory:cloudServices', function () { + var cloudServices; + + beforeEach(module('hz.dashboard', function ($provide) { + $provide.value('cinderAPI', {}); + $provide.value('glanceAPI', {}); + $provide.value('keystoneAPI', {}); + $provide.value('neutronAPI', {}); + $provide.value('novaAPI', {}); + $provide.value('novaExtensions', {}); + $provide.value('securityGroup', {}); + $provide.value('serviceCatalog', {}); + })); + + beforeEach(inject(function ($injector) { + cloudServices = $injector.get('cloudServices'); + })); + + it('should have `cloudServices` defined.', function () { + expect(cloudServices).toBeDefined(); + }); + + it('should have `cloudServices.cinder` defined.', function () { + expect(cloudServices.cinder).toBeDefined(); + }); + + it('should have `cloudServices.glance` defined.', function () { + expect(cloudServices.glance).toBeDefined(); + }); + + it('should have `cloudServices.keystone` defined.', function () { + expect(cloudServices.keystone).toBeDefined(); + }); + + it('should have `cloudServices.neutron` defined.', function () { + expect(cloudServices.neutron).toBeDefined(); + }); + + it('should have `cloudServices.nova` defined.', function () { + expect(cloudServices.nova).toBeDefined(); + }); + + it('should have `cloudServices.novaExtensions` defined.', function () { + expect(cloudServices.novaExtensions).toBeDefined(); + }); + + }); + + // + // factory:ifExtensionsEnabled + // + + describe('factory:ifExtensionsEnabled', function () { + var ifExtensionsEnabled, + $q, + cloudServices; + + beforeEach(module('hz.dashboard', function ($provide) { + $q = { + all: function () { + return { + then: function () {} + }; + } + }; + + cloudServices = { + 'someService': { + ifEnabled: function () {} + } + }; + + spyOn(cloudServices.someService, 'ifEnabled'); + spyOn($q, 'all'); + + $provide.value('$q', $q); + $provide.value('cloudServices', cloudServices); + })); + + beforeEach(inject(function ($injector) { + ifExtensionsEnabled = $injector.get('ifExtensionsEnabled'); + })); + + it('should have `ifExtensionsEnabled` defined as a function.', function () { + expect(ifExtensionsEnabled).toBeDefined(); + expect(angular.isFunction(ifExtensionsEnabled)).toBe(true); + }); + + it('should call $q.all() and someService.ifEnabled() when invoking ifExtensionsEnabled().', function () { + var extensions = ['ext1', 'ext2']; + ifExtensionsEnabled('someService', extensions); + expect($q.all).toHaveBeenCalled(); + expect(cloudServices.someService.ifEnabled).toHaveBeenCalled(); + }); + + it('should not throw when passing in an empty extensions list.', function () { + expect(function () { + ifExtensionsEnabled('someService', []); + }).not.toThrow(); + }); + + it('should throw when extensions is null or undefined or not an array', function () { + expect(function () { + ifExtensionsEnabled('someService', null); + }).toThrow(); + + expect(function () { + ifExtensionsEnabled('someService'); + }).toThrow(); + + expect(function () { + ifExtensionsEnabled('123'); + }).toThrow(); + }); + + it('should not throw when the provided serviceName is not a key in the services hash table', function () { + expect(function () { + ifExtensionsEnabled('invlidServiceName', []); + }).not.toThrow(); + }); + }); + + // + // factory:createDirectiveSpec + // + + describe('factory:createDirectiveSpec', function () { + var createDirectiveSpec, + ifExtensionsEnabled; + + beforeEach(module('hz.dashboard', function ($provide) { + ifExtensionsEnabled = function () { + return { + then: function (successCallback, errorCallback) { + } + }; + }; + $provide.value('ifExtensionsEnabled', ifExtensionsEnabled); + })); + + beforeEach(inject(function ($injector) { + createDirectiveSpec = $injector.get('createDirectiveSpec'); + })); + + it('should have `createDirectiveSpec` defined as a function.', function () { + expect(createDirectiveSpec).toBeDefined(); + expect(angular.isFunction(createDirectiveSpec)).toBe(true); + }); + + describe('When called, the returned object', function () { + var directiveSpec; + + beforeEach(function () { + directiveSpec = createDirectiveSpec('someService'); + }); + + it('should be defined.', function () { + expect(directiveSpec).toBeDefined(); + }); + + it('should have "restrict" property "E".', function () { + expect(directiveSpec.restrict).toBe('E'); + }); + + it('should have "transclude" property true.', function () { + expect(directiveSpec.transclude).toBe(true); + }); + + it('should have "link" property as a function.', function () { + expect(directiveSpec.link).toEqual(jasmine.any(Function)); + }); + + }); + + }); + + // + // directive:novaExtension + // + + describe('directive:novaExtension', function () { + var $timeout, + $scope, + html = [ + '', + '
', + '
', + '
' + ].join(''), + element; + + beforeEach(module('hz.dashboard', function ($provide) { + $provide.value('ifExtensionsEnabled', function () { + return { + then: function (successCallback, errorCallback) { + $timeout(successCallback); + } + }; + }); + })); + + beforeEach(inject(function ($injector) { + var $compile = $injector.get('$compile'); + $scope = $injector.get('$rootScope').$new(); + $timeout = $injector.get('$timeout'); + element = $compile(html)($scope); + })); + + it('should be compiled.', function () { + expect(element.hasClass('ng-scope')).toBe(true); + }); + + it('should have class name `ng-hide` by default.', function () { + expect(element.hasClass('ng-hide')).toBe(true); + }); + + it('should have no class name `ng-hide` after an asyncs callback.', function () { + $timeout(function () { + expect(element.hasClass('ng-hide')).toBe(false); + }); + $timeout.flush(); + }); + + it('should have the right child element.', function () { + expect(element.children().first().hasClass('child-element')).toBe(true); + }); + + }); + + }) + +;})(); diff --git a/openstack_dashboard/static/dashboard/launch-instance/configuration/configuration.html b/openstack_dashboard/static/dashboard/launch-instance/configuration/configuration.html index 0003affdb0..f916f301d7 100644 --- a/openstack_dashboard/static/dashboard/launch-instance/configuration/configuration.html +++ b/openstack_dashboard/static/dashboard/launch-instance/configuration/configuration.html @@ -9,24 +9,28 @@ key="user_data"> -
- - -
+ +
+ + +
+
-
- -
+ +
+ +
+
diff --git a/openstack_dashboard/static/dashboard/launch-instance/launch-instance.model.js b/openstack_dashboard/static/dashboard/launch-instance/launch-instance.model.js index 57800d6d9d..912673101f 100644 --- a/openstack_dashboard/static/dashboard/launch-instance/launch-instance.model.js +++ b/openstack_dashboard/static/dashboard/launch-instance/launch-instance.model.js @@ -387,9 +387,8 @@ volumePromises.push(cinderAPI.getVolumeSnapshots({ status: 'available' }).then(onGetVolumeSnapshots)); // Can only boot image to volume if the Nova extension is enabled. - novaExtensions.ifNameEnabled('BlockDeviceMappingV2Boot', function(){ - model.allowCreateVolumeFromImage = true; - }); + novaExtensions.ifNameEnabled('BlockDeviceMappingV2Boot') + .then(function(){ model.allowCreateVolumeFromImage = true; }); return $q.all(volumePromises); }