Adding Create Image Action to angular images panel

Adds the ability to create an image from the table.

Image File Upload is not currently supported.

To test set DISABLED = False in _1051_project_ng_images_panel.py

Co-Authored-By: Nathan Zeplowitz<nzeplowi@thoughtworks.com>
Co-Authored-By: Kyle Olivo<keolivo@thoughtworks.com>
Co-Authored-By: Errol Pais<epais@thoughtworks.com>
Co-Authored-By: Matt Borland <matt.borland@hpe.com>
Co-Authored-By: Tyr Johanson <tyr@hpe.com>

Closes-Bug: 1580206
Change-Id: I2f49b4f8c0d82b03289bb44b8c6fddd70ee67bae
Partially-Implements: blueprint angularize-images-table
This commit is contained in:
Rajat Vig 2015-10-16 11:09:30 -07:00 committed by Timur Sufiev
parent 4f9e33d4c5
commit 6ccd9fb6b4
18 changed files with 1053 additions and 46 deletions

View File

@ -30,6 +30,7 @@
registerImageActions.$inject = [
'horizon.framework.conf.resource-type-registry.service',
'horizon.app.core.images.actions.edit.service',
'horizon.app.core.images.actions.create.service',
'horizon.app.core.images.actions.create-volume.service',
'horizon.app.core.images.actions.delete-image.service',
'horizon.app.core.images.actions.launch-instance.service',
@ -40,6 +41,7 @@
function registerImageActions(
registry,
editService,
createService,
createVolumeService,
deleteImageService,
launchInstanceService,
@ -86,6 +88,14 @@
});
imageResourceType.batchActions
.append({
id: 'createImageAction',
service: createService,
template: {
text: gettext('Create Image'),
type: 'create'
}
})
.append({
id: 'batchDeleteImageAction',
service: deleteImageService,

View File

@ -0,0 +1,125 @@
/**
* (c) Copyright 2016 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';
angular
.module('horizon.app.core.images')
.factory('horizon.app.core.images.actions.create.service', createService);
createService.$inject = [
'$q',
'horizon.app.core.images.events',
'horizon.app.core.images.resourceType',
'horizon.app.core.images.actions.createWorkflow',
'horizon.app.core.metadata.service',
'horizon.app.core.openstack-service-api.glance',
'horizon.app.core.openstack-service-api.policy',
'horizon.framework.util.actions.action-result.service',
'horizon.framework.widgets.modal.wizard-modal.service',
'horizon.framework.widgets.toast.service'
];
/**
* @ngDoc factory
* @name horizon.app.core.images.actions.createService
* @Description A service to open the user wizard.
*/
function createService(
$q,
events,
resourceType,
createWorkflow,
metadataService,
glance,
policy,
actionResultService,
wizardModalService,
toast
) {
var message = {
success: gettext('Image %s was successfully created.')
};
var model = {};
var scope;
var service = {
initScope: initScope,
perform: perform,
allowed: allowed
};
return service;
//////////////
function initScope($scope) {
var watchImageChange = $scope.$on(events.IMAGE_CHANGED, onImageChange);
var watchMetadataChange = $scope.$on(events.IMAGE_METADATA_CHANGED, onMetadataChange);
scope = $scope;
$scope.$on('$destroy', destroy);
function destroy() {
watchImageChange();
watchMetadataChange();
}
}
function onImageChange(e, image) {
model.image = image;
e.stopPropagation();
}
function onMetadataChange(e, metadata) {
model.metadata = metadata;
e.stopPropagation();
}
function allowed() {
return policy.ifAllowed({ rules: [['image', 'add_image']] });
}
function perform() {
model.image = {};
model.metadata = {};
scope.image = {};
return wizardModalService.modal({
scope: scope,
workflow: createWorkflow,
submit: submit
}).result;
}
function submit() {
var finalModel = angular.extend({}, model.image, model.metadata);
return glance.createImage(finalModel).then(onCreateImage);
}
function onCreateImage(response) {
var newImage = response.data;
toast.add('success', interpolate(message.success, [newImage.name]));
return actionResultService.getActionResult()
.created(resourceType, newImage.id)
.result;
}
} // end of createService
})(); // end of IIFE

View File

@ -0,0 +1,183 @@
/**
* (c) Copyright 2016 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('horizon.app.core.images.actions.create.service', function() {
var metadataService = {
editMetadata: function() {
return {
then: function(callback) {
callback();
}
};
}
};
var wizardModalService = {
modal: function (config) {
deferredModal = $q.defer();
deferredModal.resolve(config.scope.image);
return {result: deferredModal.promise};
}
};
var glanceAPI = {
createImage: function(image) {
deferredCreate = $q.defer();
deferredCreate.resolve({data: image});
return deferredCreate.promise;
}
};
var policyAPI = {
ifAllowed: function() {
return {
success: function(callback) {
callback({allowed: true});
}
};
}
};
var service, events, $scope, toast, deferredModal, deferredCreate, $q;
///////////////////////
beforeEach(module('horizon.app.core'));
beforeEach(module('horizon.framework'));
beforeEach(module(function($provide) {
$provide.value('horizon.framework.widgets.modal.wizard-modal.service', wizardModalService);
$provide.value('horizon.app.core.openstack-service-api.glance', glanceAPI);
$provide.value('horizon.app.core.openstack-service-api.policy', policyAPI);
$provide.value('horizon.app.core.metadata.service', metadataService);
}));
beforeEach(inject(function($injector, _$rootScope_, _$q_) {
$scope = _$rootScope_.$new();
service = $injector.get('horizon.app.core.images.actions.create.service');
events = $injector.get('horizon.app.core.images.events');
toast = $injector.get('horizon.framework.widgets.toast.service');
$q = _$q_;
}));
it('should check the policy if the user is allowed to delete images', function() {
spyOn(policyAPI, 'ifAllowed').and.callThrough();
var allowed = service.allowed();
expect(allowed).toBeTruthy();
expect(policyAPI.ifAllowed).toHaveBeenCalledWith({ rules: [['image', 'add_image']] });
});
it('open the modal with the correct parameters', function() {
spyOn(wizardModalService, 'modal').and.callThrough();
service.initScope($scope);
service.perform();
expect(wizardModalService.modal).toHaveBeenCalled();
expect($scope.image).toEqual({});
var modalArgs = wizardModalService.modal.calls.argsFor(0)[0];
expect(modalArgs.scope).toEqual($scope);
expect(modalArgs.workflow).toBeDefined();
expect(modalArgs.submit).toBeDefined();
});
it('should submit create image request to glance', function() {
var image = { name: 'Test', id: '2' };
var newMetadata = {prop1: '11', prop3: '3'};
spyOn($scope, '$emit').and.callThrough();
spyOn(glanceAPI, 'createImage').and.callThrough();
spyOn(toast, 'add').and.callThrough();
spyOn(wizardModalService, 'modal').and.callThrough();
service.initScope($scope);
service.perform();
$scope.$emit(events.IMAGE_CHANGED, image);
$scope.$emit(events.IMAGE_METADATA_CHANGED, newMetadata);
var modalArgs = wizardModalService.modal.calls.argsFor(0)[0];
modalArgs.submit();
$scope.$apply();
expect(glanceAPI.createImage).toHaveBeenCalledWith({ name: 'Test',
id: '2', prop1: '11', prop3: '3'});
expect(toast.add).toHaveBeenCalledWith('success', 'Image Test was successfully created.');
});
it('should raise event even if update meta data fails', function() {
var image = { name: 'Test', id: '2' };
var failedPromise = function() {
return {
then: function(callback, errorCallback) {
errorCallback();
}
};
};
spyOn(wizardModalService, 'modal').and.callThrough();
spyOn(glanceAPI, 'createImage').and.callThrough();
spyOn(metadataService, 'editMetadata').and.callFake(failedPromise);
spyOn($scope, '$emit').and.callThrough();
spyOn(toast, 'add').and.callThrough();
service.initScope($scope);
service.perform();
$scope.$apply();
$scope.$emit(events.IMAGE_CHANGED, image);
$scope.$emit(events.IMAGE_METADATA_CHANGED, newMetadata);
var newMetadata = {prop1: '11', prop3: '3'};
var modalArgs = wizardModalService.modal.calls.argsFor(0)[0];
modalArgs.submit();
$scope.$apply();
expect(toast.add.calls.count()).toBe(1);
});
it('should destroy the event watchers', function() {
var newImage = { name: 'Test2', id: '2' };
var newMetadata = {p1: '11', p3: '3'};
spyOn(wizardModalService, 'modal').and.callThrough();
spyOn(glanceAPI, 'createImage').and.callThrough();
spyOn(metadataService, 'editMetadata').and.callThrough();
spyOn(toast, 'add').and.callThrough();
service.initScope($scope);
service.perform();
$scope.$apply();
$scope.$emit('$destroy');
$scope.$emit(events.IMAGE_CHANGED, newImage);
$scope.$emit(events.IMAGE_METADATA_CHANGED, newMetadata);
var modalArgs = wizardModalService.modal.calls.argsFor(0)[0];
modalArgs.submit();
$scope.$apply();
expect(glanceAPI.createImage).toHaveBeenCalledWith({});
expect(toast.add.calls.count()).toBe(1);
});
});
})();

View File

@ -0,0 +1,58 @@
/**
* (c) Copyright 2016 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';
angular
.module('horizon.app.core.images')
.factory('horizon.app.core.images.actions.createWorkflow', createWorkflow);
createWorkflow.$inject = [
'horizon.app.core.images.basePath',
'horizon.app.core.workflow.factory',
'horizon.framework.util.i18n.gettext'
];
/**
* @ngdoc factory
* @name horizon.app.core.images.createWorkflow
* @description A workflow for the create image action.
*/
function createWorkflow(basePath, workflowService, gettext) {
var workflow = workflowService({
title: gettext('Create Image'),
btnText: { finish: gettext('Create Image') },
steps: [
{
title: gettext('Image Details'),
templateUrl: basePath + 'steps/create-image/create-image.html',
helpUrl: basePath + 'steps/create-image/create-image.help.html',
formName: 'imageForm'
},
{
title: gettext('Metadata'),
templateUrl: basePath + 'steps/update-metadata/update-metadata.html',
helpUrl: basePath + 'steps/update-metadata/update-metadata.help.html',
formName: 'updateMetadataForm'
}
]
});
return workflow;
}
})();

View File

@ -0,0 +1,56 @@
/**
* (c) Copyright 2016 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('horizon.app.core.images.actions.createWorkflow', function() {
var mockWorkflow = function(params) {
return params;
};
var service;
///////////////////////
beforeEach(module('horizon.framework.util'));
beforeEach(module('horizon.app.core'));
beforeEach(module('horizon.app.core.workflow', function($provide) {
$provide.value('horizon.app.core.workflow.factory', mockWorkflow);
}));
beforeEach(module('horizon.app.core.images', function($provide) {
$provide.constant('horizon.app.core.images.basePath', '/dummy/');
}));
beforeEach(inject(function($injector) {
service = $injector.get('horizon.app.core.images.actions.createWorkflow');
}));
it('create the workflow for creating image', function() {
expect(service.title).toEqual('Create Image');
expect(service.steps.length).toEqual(2);
expect(service.steps[0].templateUrl).toEqual('/dummy/steps/create-image/create-image.html');
expect(service.steps[1].templateUrl).toEqual(
'/dummy/steps/update-metadata/update-metadata.html'
);
});
});
})();

View File

@ -1,4 +1,6 @@
/**
* (c) Copyright 2016 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
@ -101,12 +103,16 @@
scope: scope,
workflow: editWorkflow,
submit: submit
});
}).result.catch(cancel);
saveDeferred = $q.defer();
return saveDeferred.promise;
}
function cancel() {
saveDeferred.reject();
}
function submit() {
return saveMetadata().then(onSaveMetadata, onFailMetadata);
}

View File

@ -1,6 +1,8 @@
/**
* (c) Copyright 2016 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use self file except in compliance with the License. You may obtain
* 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
@ -39,7 +41,7 @@
var wizardModalService = {
modal: function () {
return { result: {} };
return { result: {catch: angular.noop} };
}
};

View File

@ -1,5 +1,5 @@
/**
* (c) Copyright 2015 Hewlett-Packard Development Company, L.P.
* (c) Copyright 2016 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
@ -25,7 +25,6 @@
describe('horizon.app.core.images.tableRoute constant', function () {
var tableRoute;
beforeEach(module('horizon.app.core'));
beforeEach(module('horizon.app.core.images'));
beforeEach(inject(function ($injector) {
tableRoute = $injector.get('horizon.app.core.images.tableRoute');

View File

@ -0,0 +1,142 @@
/**
* (c) Copyright 2016 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';
angular
.module('horizon.app.core.images')
.controller('horizon.app.core.images.steps.CreateImageController', CreateImageController);
CreateImageController.$inject = [
'$scope',
'horizon.app.core.openstack-service-api.glance',
'horizon.app.core.images.events',
'horizon.app.core.images.imageFormats',
'horizon.app.core.images.validationRules',
'horizon.app.core.openstack-service-api.settings'
];
/**
* @ngdoc controller
* @name horizon.app.core.images.steps.CreateImageController
* @description
* This controller is use for creating an image.
*/
function CreateImageController(
$scope,
glance,
events,
imageFormats,
validationRules,
settings
) {
var ctrl = this;
settings.getSettings().then(getConfiguredFormats);
ctrl.validationRules = validationRules;
ctrl.imageFormats = imageFormats;
ctrl.diskFormats = [];
ctrl.image = {
source_type: 'url',
image_url: '',
is_copying: true,
protected: false,
min_disk: 0,
min_ram: 0,
container_format: '',
disk_format: '',
visibility: 'public'
};
ctrl.imageProtectedOptions = [
{ label: gettext('Yes'), value: true },
{ label: gettext('No'), value: false }
];
ctrl.imageCopyOptions = [
{ label: gettext('Yes'), value: true },
{ label: gettext('No'), value: false }
];
ctrl.imageVisibilityOptions = [
{ label: gettext('Public'), value: 'public'},
{ label: gettext('Private'), value: 'private' }
];
ctrl.kernelImages = [];
ctrl.ramdiskImages = [];
ctrl.setFormats = setFormats;
init();
var imageChangedWatcher = $scope.$watchCollection('ctrl.image', watchImageCollection);
$scope.$on('$destroy', function() {
imageChangedWatcher();
});
///////////////////////////
function getConfiguredFormats(response) {
var settingsFormats = response.OPENSTACK_IMAGE_FORMATS;
var dupe = angular.copy(imageFormats);
angular.forEach(dupe, function stripUnknown(name, key) {
if (settingsFormats.indexOf(key) === -1) {
delete dupe[key];
}
});
ctrl.imageFormats = dupe;
}
// emits new data to parent listeners
function watchImageCollection(newValue, oldValue) {
if (newValue !== oldValue) {
$scope.$emit(events.IMAGE_CHANGED, newValue);
}
}
function init() {
glance.getImages({paginate: false}).success(onGetImages);
}
function onGetImages(response) {
ctrl.kernelImages = response.items.filter(function(elem) {
return elem.disk_format === 'aki';
});
ctrl.ramdiskImages = response.items.filter(function(elem) {
return elem.disk_format === 'ari';
});
}
function setFormats() {
ctrl.image.container_format = 'bare';
if (['aki', 'ami', 'ari'].indexOf(ctrl.image_format) > -1) {
ctrl.image.container_format = ctrl.image_format;
}
ctrl.image.disk_format = ctrl.image_format;
if (ctrl.image_format === 'docker') {
ctrl.image.container_format = 'docker';
ctrl.image.disk_format = 'raw';
}
}
} // end of controller
})();

View File

@ -0,0 +1,184 @@
/**
* (c) Copyright 2016 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('horizon.app.core.images create image controller', function() {
function fakeGlance() {
return {
success: function(callback) {
callback({
items: [
{disk_format: 'aki'},
{disk_format: 'ari'},
{disk_format: ''}]
});
}
};
}
var controller, glanceAPI, $scope, events, $q, settingsCall, $timeout;
///////////////////////
beforeEach(module('horizon.framework'));
beforeEach(module('horizon.app.core'));
beforeEach(inject(function ($injector, _$rootScope_, _$q_, _$timeout_) {
$scope = _$rootScope_.$new();
$q = _$q_;
$timeout = _$timeout_;
glanceAPI = $injector.get('horizon.app.core.openstack-service-api.glance');
events = $injector.get('horizon.app.core.images.events');
controller = $injector.get('$controller');
spyOn(glanceAPI, 'getImages').and.callFake(fakeGlance);
}));
function createController() {
var settings = {
getSettings: function() {
settingsCall = $q.defer();
return settingsCall.promise;
}
};
var imageFormats = {
'a': 'apple',
'b': 'banana',
'c': 'cherry',
'd': 'django'
};
return controller('horizon.app.core.images.steps.CreateImageController as ctrl', {
$scope: $scope,
glanceAPI: glanceAPI,
events: events,
'horizon.app.core.openstack-service-api.settings': settings,
'horizon.app.core.images.imageFormats': imageFormats
});
}
it('should call glance API on init', function() {
var ctrl = createController();
expect(glanceAPI.getImages).toHaveBeenCalledWith({paginate: false});
expect(ctrl.kernelImages).toEqual([{disk_format: 'aki'}]);
expect(ctrl.ramdiskImages).toEqual([{disk_format: 'ari'}]);
});
it('should emit events on image change', function() {
spyOn($scope, '$emit').and.callThrough();
var ctrl = createController();
ctrl.image = 1;
$scope.$apply();
ctrl.image = 2;
$scope.$apply();
expect($scope.$emit).toHaveBeenCalledWith('horizon.app.core.images.IMAGE_CHANGED', 2);
});
it('should have options for visibility, protected and copying', function() {
var ctrl = createController();
expect(ctrl.imageVisibilityOptions.length).toEqual(2);
expect(ctrl.imageProtectedOptions.length).toEqual(2);
expect(ctrl.imageCopyOptions.length).toEqual(2);
});
it("should destroy the image changed watcher when the controller is destroyed", function() {
spyOn($scope, '$emit').and.callThrough();
var ctrl = createController();
ctrl.image = 1;
$scope.$apply();
$scope.$emit("$destroy");
$scope.$emit.calls.reset();
ctrl.image = 2;
$scope.$apply();
expect($scope.$emit).not.toHaveBeenCalled();
});
it("should set the default values", function() {
var ctrl = createController();
expect(ctrl.imageFormats).toBeDefined();
expect(ctrl.validationRules).toBeDefined();
expect(ctrl.diskFormats).toEqual([]);
expect(ctrl.image.visibility).toEqual('public');
expect(ctrl.image.min_disk).toEqual(0);
expect(ctrl.image.min_ram).toEqual(0);
});
describe('setFormats', function() {
var ctrl;
beforeEach(function() {
ctrl = createController();
});
it('assumes bare container format', function() {
ctrl.image_format = 'unknown';
ctrl.setFormats();
expect(ctrl.image.container_format).toBe('bare');
});
it('uses the given image format', function() {
ctrl.image_format = 'unknown';
ctrl.setFormats();
expect(ctrl.image.disk_format).toBe('unknown');
});
it('sets container to ami/aki/ari if format is ami/aki/ari', function() {
['ami', 'aki', 'ari'].forEach(function(format) {
ctrl.image_format = format;
ctrl.setFormats();
expect(ctrl.image.disk_format).toBe(format);
expect(ctrl.image.container_format).toBe(format);
});
});
it('sets docker/raw for container/disk if type is docker', function() {
ctrl.image_format = 'docker';
ctrl.setFormats();
expect(ctrl.image.disk_format).toBe('raw');
expect(ctrl.image.container_format).toBe('docker');
});
});
describe('getConfiguredFormats', function() {
it('uses the settings for the source of allowed image formats', function() {
var ctrl = createController();
settingsCall.resolve({OPENSTACK_IMAGE_FORMATS: ['a', 'b', 'c']});
$timeout.flush();
var expectation = {
'a': 'apple',
'b': 'banana',
'c': 'cherry'
};
expect(ctrl.imageFormats).toEqual(expectation);
});
});
});
})();

View File

@ -0,0 +1,10 @@
<div>
<h3 translate>Description:</h3>
<p translate>
Currently only images available via an HTTP URL are supported. The image location must be accessible to the Image Service. Compressed image binaries are supported (.zip and .tar.gz.)
</p>
<p>
<strong translate>Please note: </strong>
<translate>The Image Location field MUST be a valid and direct URL to the image binary. URLs that redirect or serve error pages will result in unusable images.</translate>
</p>
</div>

View File

@ -0,0 +1,208 @@
<div ng-controller="horizon.app.core.images.steps.CreateImageController as ctrl">
<h3 translate>Image Details</h3>
<div class="content">
<div class="subtitle">
<translate>
Specify an image to upload to the Image Service.
</translate>
</div>
<div class="selected-source">
<div class="row form-group">
<div class="col-xs-6 col-sm-6">
<div class="form-group required"
ng-class="{'has-error':imageForm.name.$invalid && imageForm.name.$dirty}">
<label class="control-label required" for="imageForm-name">
<translate>Image Name</translate><span class="hz-icon-required fa fa-asterisk"></span>
</label>
<input required id="imageForm-name" name="name"
type="text" class="form-control"
ng-model="ctrl.image.name"
ng-maxlength="ctrl.validationRules.fieldMaxLength">
<p class="help-block alert alert-danger"
ng-show="imageForm.name.$invalid && imageForm.name.$dirty">
<translate>An image name less than {$ctrl.validationRules.fieldMaxLength + 1$} characters is required.</translate>
</p>
</div>
</div>
<div class="col-xs-6 col-sm-6">
<div class="form-group"
ng-class="{'has-error':imageForm.description.$invalid && imageForm.description.$dirty}">
<label class="control-label" for="imageForm-description">
<translate>Image Description</translate>
</label>
<input id="imageForm-description" name="description"
type="text" class="form-control"
ng-model="ctrl.image.description"
ng-maxlength="ctrl.validationRules.fieldMaxLength">
<p class="help-block alert alert-danger"
ng-show="imageForm.description.$invalid && imageForm.description.$dirty">
<translate>An image description less than {$ctrl.validationRules.fieldMaxLength + 1$} characters is required.</translate>
</p>
</div>
</div>
</div>
</div>
<h3 class="section-title" translate>Image Source</h3>
<div class="subtitle"></div>
<div class="selected-source">
<div class="row form-group">
<div class="col-xs-6 col-sm-6" ng-if="ctrl.image.source_type === 'url'">
<div class="form-group required"
ng-class="{'has-error':imageForm.image_url.$invalid && imageForm.image_url.$dirty}">
<label class="control-label" for="imageForm-image_url">
<translate>Location</translate><span class="hz-icon-required fa fa-asterisk"></span>
</label>
<input ng-required="true" id="imageForm-image_url" name="image_url"
type="text" class="form-control"
ng-model="ctrl.image.image_url"
ng-maxlength="ctrl.validationRules.fieldMaxLength"
placeholder="{$ 'An external (HTTP) URL to load the image from'|translate $}">
<p class="help-block alert alert-danger"
ng-show="imageForm.image_url.$invalid && imageForm.image_url.$dirty">
<translate>An external (HTTP) URL is required</translate>
</p>
</div>
</div>
<div class="col-xs-6 col-sm-6" ng-if="ctrl.image.source_type === 'url'">
<div class="form-group">
<label class="control-label required">
<translate>Copy Data</translate>
</label>
<div class="form-field">
<div class="btn-group">
<label class="btn btn-toggle"
ng-repeat="option in ctrl.imageCopyOptions"
ng-model="ctrl.image.is_copying"
btn-radio="option.value">{$ ::option.label $}</label>
</div>
</div>
</div>
</div>
</div>
<div class="row form-group">
<div class="col-xs-6 col-sm-6">
<div class="form-group required">
<label class="control-label required" for="imageForm-container_format" translate>Format</label><span class="hz-icon-required fa fa-asterisk"></span>
<select class="form-control switchable ng-pristine ng-untouched ng-valid" ng-required="true" id="imageForm-format" name="format" ng-model="ctrl.image_format" ng-options="key as label for (key, label) in ctrl.imageFormats" ng-change="ctrl.setFormats()">
</select>
</div>
</div>
</div>
</div>
<h3 class="section-title" translate>Image Requirements</h3>
<div class="subtitle"></div>
<div class="selected-source">
<div class="row form-group">
<div class="col-xs-6 col-sm-6">
<div class="form-group" for="imageForm-kernel">
<label class="control-label">
<translate>Kernel</translate>
</label>
<select class="form-control" id="imageForm-kernel" name="kernel" ng-model="ctrl.image.kernel">
<option value="" selected="selected" translate>Choose an image</option>
<option ng-repeat="kernel in ctrl.kernelImages" value="{$ kernel.id $}">{$ kernel.name $}</option>
</select>
</div>
</div>
<div class="col-xs-6 col-sm-6">
<div class="form-group">
<label class="control-label" for="imageForm-ramdisk">
<translate>Ramdisk</translate>
</label>
<select class="form-control" id="imageForm-ramdisk" name="ramdisk" ng-model="ctrl.image.ramdisk">
<option value="" selected="selected" translate>Choose an image</option>
<option ng-repeat="ramdisk in ctrl.ramdiskImages" value="{$ ramdisk.id $}">{$ ramdisk.name $}</option>
</select>
</div>
</div>
<div class="col-xs-6 col-sm-6">
<div class="form-group">
<label class="control-label" for="imageForm-architecture">
<translate>Architecture</translate>
</label>
<input id="imageForm-architecture" name="architecture"
type="text" class="form-control"
ng-model="ctrl.image.architecture"
ng-maxlength="ctrl.validationRules.fieldMaxLength">
</div>
</div>
<div class="col-xs-6 col-sm-3">
<div class="form-group required"
ng-class="{'has-error':imageForm.min_disk.$invalid && imageForm.min_disk.$dirty}">
<label class="control-label" for="imageForm-min_disk">
<translate>Minimum Disk (GB)</translate>
</label>
<input id="imageForm-min_disk" name="min_disk"
type="number" class="form-control"
ng-required="true"
ng-pattern="ctrl.validationRules.integer"
ng-model="ctrl.image.min_disk"
min=0>
</div>
</div>
<div class="col-xs-6 col-sm-3">
<div class="form-group required"
ng-class="{'has-error':imageForm.min_ram.$invalid && imageForm.min_ram.$dirty}">
<label class="control-label required">
<translate>Minimum RAM (MB)</translate>
</label>
<input id="imageForm-min_ram" name="min_ram"
type="number" class="form-control"
ng-required="true"
ng-pattern="ctrl.validationRules.integer" ng-model="ctrl.image.min_ram"
min=0>
</div>
</div>
</div>
</div>
<h3 class="section-title" translate>Image Sharing</h3>
<div class="subtitle"></div>
<div class="selected-source">
<div class="row">
<div class="col-xs-6 col-sm-6">
<div class="form-group">
<label class="control-label required">
<translate>Visibility</translate>
</label>
<div class="form-field">
<div class="btn-group">
<label class="btn btn-toggle"
ng-repeat="option in ctrl.imageVisibilityOptions"
ng-model="ctrl.image.visibility"
btn-radio="option.value">{$ ::option.label $}</label>
</div>
</div>
</div>
</div>
<div class="col-xs-6 col-sm-6">
<div class="form-group">
<label class="control-label required">
<translate>Protected</translate>
</label>
<div class="form-field">
<div class="btn-group">
<label class="btn btn-toggle"
ng-repeat="option in ctrl.imageProtectedOptions"
ng-model="ctrl.image.protected"
btn-radio="option.value">{$ ::option.label $}</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,4 +1,5 @@
/**
* (c) Copyright 2016 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
@ -61,12 +62,6 @@
$scope.imagePromise.then(init);
var imageChangedWatcher;
$scope.$on('$destroy', function() {
imageChangedWatcher();
});
///////////////////////////
function getConfiguredFormats(response) {
@ -93,14 +88,6 @@
ctrl.image.disk_format = 'raw';
}
setFormats();
imageChangedWatcher = $scope.$watchCollection('ctrl.image', watchImageCollection);
}
// emits new data to parent listeners
function watchImageCollection(newValue, oldValue) {
if (newValue !== oldValue) {
$scope.$emit(events.IMAGE_CHANGED, newValue);
}
}
function setFormats() {

View File

@ -134,20 +134,6 @@
expect(ctrl.image.container_format).toEqual('ari');
});
it('should emit events on image change', function() {
spyOn($scope, '$emit').and.callThrough();
setImagePromise({id: '1', container_format: 'bare', properties: []});
var ctrl = createController();
ctrl.image = 1;
$scope.$apply();
ctrl.image = 2;
$scope.$apply();
expect($scope.$emit).toHaveBeenCalledWith('horizon.app.core.images.IMAGE_CHANGED', 2);
});
it("should destroy the image changed watcher when the controller is destroyed", function() {
setImagePromise({id: '1', container_format: 'bare', properties: []});
spyOn($scope, '$emit').and.callThrough();

View File

@ -1,15 +1,17 @@
/*
* 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
/**
* (c) Copyright 2016 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.
* 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';
@ -47,7 +49,15 @@
$scope.$watchCollection(getTree, onMetadataChanged);
/* eslint-enable angular/ng_controller_as */
$scope.imagePromise.then(init);
if ($scope.imagePromise) {
// Launched from an image.
$scope.imagePromise.then(init);
} else {
$q.all({
available: metadataService.getNamespaces('image'),
existing: getExistingMetdataPromise({})
}).then(onMetadataGet);
}
////////////////////////////////
@ -55,7 +65,7 @@
var image = response.data;
$q.all({
available: metadataService.getNamespaces('image'),
existing: metadataService.getMetadata('image', image.id)
existing: getExistingMetdataPromise(image)
}).then(onMetadataGet);
}
@ -70,6 +80,16 @@
return ctrl.tree.getExisting();
}
function getExistingMetdataPromise(image) {
if (angular.isDefined(image.id)) {
return metadataService.getMetadata('image', image.id);
} else {
var deferred = $q.defer();
deferred.resolve({data: []});
return deferred.promise;
}
}
function onMetadataChanged(newValue, oldValue) {
if (newValue !== oldValue) {
$scope.$emit(events.IMAGE_METADATA_CHANGED, newValue);

View File

@ -1,4 +1,5 @@
/*
/**
* (c) Copyright 2016 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
@ -87,6 +88,36 @@
expect(metadataTreeService.Tree).toHaveBeenCalledWith(availableMetadata, existingMetadata);
});
it('should setup up the metadata tree even without an image', function() {
expect($scope.imagePromise).toBeUndefined();
spyOn(metadataTreeService, 'Tree').and.returnValue(mockTree);
spyOn(metadataService, 'getNamespaces').and.callThrough();
spyOn(metadataService, 'getMetadata').and.callThrough();
var ctrl = createController();
$scope.$apply();
expect(ctrl.tree).toEqual(mockTree);
expect(metadataTreeService.Tree).toHaveBeenCalledWith(availableMetadata, []);
});
it('should setup up the metadata tree if image does not exist', function() {
var deferred = $q.defer();
deferred.resolve({data: {}});
$scope.imagePromise = deferred.promise;
spyOn(metadataTreeService, 'Tree').and.returnValue(mockTree);
spyOn(metadataService, 'getNamespaces').and.callThrough();
var ctrl = createController();
$scope.$apply();
expect(ctrl.tree).toEqual(mockTree);
expect(metadataTreeService.Tree).toHaveBeenCalledWith([], []);
expect(metadataTreeService.Tree).toHaveBeenCalledWith(availableMetadata, []);
});
it('should emit imageMetadataChanged event when metadata changes', function() {
var deferred = $q.defer();
deferred.resolve({data: {id: '1'}});

View File

@ -1,5 +1,5 @@
<div>
<h1 translate>Metadata Help</h1>
<h3 translate>Metadata Help</h3>
<p translate>You can add arbitrary metadata to your image.</p>
<p translate>
Metadata is used to provide additional information about the

View File

@ -1,6 +1,6 @@
<div ng-controller="horizon.app.core.images.steps.UpdateMetadataController as metadataCtrl">
<h1 translate>Image Metadata</h1>
<div class="content">
<metadata-tree model="metadataCtrl.tree" form="imageForm"></metadata-tree>
<metadata-tree model="metadataCtrl.tree" form="updateMetadataForm"></metadata-tree>
</div>
</div>