Adding Edit Image Action to angular images panel

Adds the ability to edit an image from the table.

To test set DISABLED = False in _1051_project_ng_images_panel.py

Also, ensure in your local_settings.py that you set:
REST_API_REQUIRED_SETTINGS to include 'OPENSTACK_IMAGE_FORMATS'

Co-Authored-By: Rajat Vig<rajatv@thoughtworks.com>
Co-Authored-By: Nathan Zeplowitz<nzeplowi@thoughtworks.com>
Co-Authored-By: Matt Borland <matt.borland@hpe.com>
Co-Authored-By: Kyle Olivo<keolivo@thoughtworks.com>

Change-Id: I046eca486ebc0d2d980ee706105e202bf9b15ba8
Partially-Implements: blueprint angularize-images-table
This commit is contained in:
Rajat Vig 2015-10-09 23:25:13 -07:00 committed by Timur Sufiev
parent 65eb9e9b8c
commit 5b06cda113
20 changed files with 1337 additions and 9 deletions

View File

@ -297,10 +297,13 @@ def create_image_metadata(data):
'container_format': data.get('container_format'),
'properties': {}}
# 'description' and 'architecture' will be directly mapped
# 'architecture' will be directly mapped
# into the .properties by the handle_unknown_properties function.
# 'kernel' and 'ramdisk' need to get specifically mapped for backwards
# compatibility.
props = data.get('properties')
if props and props.get('description'):
meta['properties']['description'] = props.get('description')
if data.get('kernel'):
meta['properties']['kernel_id'] = data.get('kernel')
if data.get('ramdisk'):
@ -320,9 +323,13 @@ def handle_unknown_properties(data, meta):
# existing horizon api wrapper, we need this function. This way, the
# client REST mirrors the Glance API.
known_props = ['visibility', 'protected', 'disk_format',
'container_format', 'min_disk', 'min_ram',
'name', 'properties', 'kernel', 'ramdisk',
'tags', 'import_data', 'source', 'image_url', 'source_type']
'container_format', 'min_disk', 'min_ram', 'name',
'properties', 'kernel', 'ramdisk',
'tags', 'import_data', 'source',
'image_url', 'source_type',
'checksum', 'created_at', 'deleted', 'is_copying',
'deleted_at', 'is_public', 'virtual_size',
'status', 'size', 'owner', 'id', 'updated_at']
other_props = {k: v for (k, v) in data.items() if k not in known_props}
meta['properties'].update(other_props)

View File

@ -720,7 +720,8 @@ SECURITY_GROUP_RULES = {
# You should not add settings to this list for out of tree extensions.
# See: https://wiki.openstack.org/wiki/Horizon/RESTAPI
REST_API_REQUIRED_SETTINGS = ['OPENSTACK_HYPERVISOR_FEATURES',
'LAUNCH_INSTANCE_DEFAULTS']
'LAUNCH_INSTANCE_DEFAULTS',
'OPENSTACK_IMAGE_FORMATS']
# Additional settings can be made available to the client side for
# extensibility by specifying them in REST_API_ADDITIONAL_SETTINGS

View File

@ -305,6 +305,11 @@ if os.path.exists(LOCAL_SETTINGS_DIR_PATH):
logging.exception(
"Can not exec settings snippet %s" % filename)
# The purpose of OPENSTACK_IMAGE_FORMATS is to provide a simple object
# that does not contain the lazy-loaded translations, so the list can
# be sent as JSON to the client-side (Angular).
OPENSTACK_IMAGE_FORMATS = [fmt for (fmt, name)
in OPENSTACK_IMAGE_BACKEND['image_formats']]
if not WEBROOT.endswith('/'):
WEBROOT += '/'

View File

@ -29,6 +29,7 @@
registerImageActions.$inject = [
'horizon.framework.conf.resource-type-registry.service',
'horizon.app.core.images.actions.edit.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',
@ -38,6 +39,7 @@
function registerImageActions(
registry,
editService,
createVolumeService,
deleteImageService,
launchInstanceService,
@ -60,6 +62,13 @@
text: gettext('Create Volume')
}
})
.append({
id: 'editAction',
service: editService,
template: {
text: gettext('Edit Image')
}
})
.append({
id: 'updateMetadataService',
service: updateMetadataService,

View File

@ -0,0 +1,176 @@
/**
* 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.edit.service', editService);
editService.$inject = [
'$q',
'horizon.app.core.images.events',
'horizon.app.core.images.actions.editWorkflow',
'horizon.app.core.metadata.service',
'horizon.app.core.openstack-service-api.glance',
'horizon.app.core.openstack-service-api.policy',
'horizon.app.core.openstack-service-api.userSession',
'horizon.framework.util.q.extensions',
'horizon.framework.widgets.modal.wizard-modal.service',
'horizon.framework.widgets.toast.service'
];
/**
* @ngDoc factory
* @name horizon.app.core.images.actions.editService
* @Description A service to open the user wizard.
*/
function editService(
$q,
events,
editWorkflow,
metadataService,
glance,
policy,
userSessionService,
$qExtensions,
wizardModalService,
toast
) {
var message = {
success: gettext('Image %s was successfully updated.'),
successMetadata: gettext('Image metadata %s was successfully updated.')
};
var modifyImagePolicyCheck, scope;
var model = {
image: {},
metadata: {}
};
var service = {
initScope: initScope,
allowed: allowed,
perform: perform
};
return service;
//////////////
// include this function in your service
// if you plan to emit events to the parent controller
function initScope($scope) {
var watchImageChange = $scope.$on(events.IMAGE_CHANGED, onImageChange);
var watchMetadataChange = $scope.$on(events.IMAGE_METADATA_CHANGED, onMetadataChange);
scope = $scope;
modifyImagePolicyCheck = policy.ifAllowed({rules: [['image', 'modify_image']]});
$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(image) {
return $q.all([
modifyImagePolicyCheck,
userSessionService.isCurrentProject(image.owner),
isActive(image)
]);
}
function perform(image) {
var deferred = glance.getImage(image.id);
deferred.then(onLoad);
scope.imagePromise = deferred;
function onLoad(response) {
var localImage = response.data;
model.image = localImage;
}
return wizardModalService.modal({
scope: scope,
workflow: editWorkflow,
submit: submit
}).result;
}
function submit() {
return saveMetadata().then(onSaveMetadata, onFailMetadata);
}
function onFailMetadata() {
glance.updateImage(model.image).then(onUpdateImageSuccess, onUpdateImageFail);
}
function onSaveMetadata() {
toast.add('success', interpolate(message.successMetadata, [model.image.name]));
glance.updateImage(model.image).then(onUpdateImageSuccess, onUpdateImageFail);
}
function onUpdateImageSuccess() {
toast.add('success', interpolate(message.success, [model.image.name]));
scope.$emit(events.UPDATE_SUCCESS, model.image);
return {
// This will be filled out with useful information as it is
// decided upon.
};
}
function onUpdateImageFail() {
scope.$emit(events.UPDATE_SUCCESS, model.image);
}
function saveMetadata() {
var imageId = model.image.id;
var deferred = $q.defer();
metadataService.getMetadata('image', imageId).then(onMetadataGet);
function onMetadataGet(response) {
var removed = angular.copy(response.data);
angular.forEach(model.metadata, function(value, key) {
delete removed[key];
});
deferred.resolve(
metadataService.editMetadata('image', imageId, model.metadata, Object.keys(removed))
);
}
return deferred.promise;
}
function isActive(image) {
return $qExtensions.booleanAsPromise(image.status === 'active');
}
} // end of editService
})(); // end of IIFE

View File

@ -0,0 +1,264 @@
/**
* 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
* 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.edit.service', function() {
var existingMetadata = {p1: '1', p2: '2'};
var metadataService = {
getMetadata: function() {
return {
then: function(callback) {
callback({
data: existingMetadata
});
}
};
},
editMetadata: function() {
return {
then: function(callback) {
callback();
}
};
}
};
var wizardModalService = {
modal: function () {
return { result: {} };
}
};
var glanceAPI = {
updateImage: function(image) {
return {
then: function(callback) {
callback({data: image});
}
};
},
getImage: function() {
var imageLoad = $q.defer();
imageLoad.resolve({data: testImage});
return imageLoad.promise;
}
};
var policyAPI = {
ifAllowed: function() {
return {
success: function(callback) {
callback({allowed: true});
}
};
}
};
var userSession = {
isCurrentProject: function() {
deferred.resolve();
return deferred.promise;
}
};
var service, events, $scope, $q, toast, deferred, testImage, $timeout;
///////////////////////
beforeEach(module('horizon.framework'));
beforeEach(module('horizon.app.core'));
beforeEach(module(function($provide) {
$provide.value('horizon.app.core.openstack-service-api.glance', glanceAPI);
$provide.value('horizon.app.core.openstack-service-api.userSession', userSession);
$provide.value('horizon.app.core.openstack-service-api.policy', policyAPI);
$provide.value('horizon.app.core.metadata.service', metadataService);
$provide.value('horizon.framework.widgets.modal.wizard-modal.service', wizardModalService);
}));
beforeEach(inject(function($injector, _$rootScope_, _$q_, _$timeout_) {
$scope = _$rootScope_.$new();
$q = _$q_;
service = $injector.get('horizon.app.core.images.actions.edit.service');
events = $injector.get('horizon.app.core.images.events');
toast = $injector.get('horizon.framework.widgets.toast.service');
service.initScope($scope);
deferred = $q.defer();
$timeout = _$timeout_;
}));
describe('perform', function() {
it('should open the modal with the correct parameters', function() {
spyOn(wizardModalService, 'modal').and.callThrough();
testImage = {id: '12'};
service.initScope($scope);
service.perform(testImage);
$timeout.flush();
expect(wizardModalService.modal).toHaveBeenCalled();
expect($scope.imagePromise).toBeDefined();
var modalArgs = wizardModalService.modal.calls.argsFor(0)[0];
expect(modalArgs.scope).toEqual($scope);
expect(modalArgs.workflow).toBeDefined();
});
it('should update image in glance, update metadata and raise event', function() {
testImage = { name: 'Test', id: '2' };
var newImage = { name: 'Test2', id: '2' };
var newMetadata = {p1: '11', p3: '3'};
spyOn($scope, '$emit').and.callThrough();
spyOn(glanceAPI, 'updateImage').and.callThrough();
spyOn(metadataService, 'editMetadata').and.callThrough();
spyOn(toast, 'add').and.callThrough();
spyOn(wizardModalService, 'modal').and.callThrough();
service.initScope($scope);
service.perform(testImage);
$timeout.flush();
$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.updateImage).toHaveBeenCalledWith(newImage);
expect(metadataService.editMetadata)
.toHaveBeenCalledWith('image', '2', newMetadata, ['p2']);
expect(toast.add)
.toHaveBeenCalledWith('success', 'Image Test2 was successfully updated.');
expect(toast.add.calls.count()).toBe(2);
expect($scope.$emit)
.toHaveBeenCalledWith('horizon.app.core.images.UPDATE_SUCCESS', newImage);
});
it('should raise event even if update meta data fails', function() {
var image = { name: 'Test', id: '2' };
var newImage = { name: 'Test2', id: '2' };
var newMetadata = {prop1: '11', prop3: '3'};
var failedPromise = function() {
return {
then: function(callback, errorCallback) {
errorCallback();
}
};
};
spyOn(wizardModalService, 'modal').and.callThrough();
spyOn(glanceAPI, 'updateImage').and.callThrough();
spyOn(metadataService, 'editMetadata').and.callFake(failedPromise);
spyOn($scope, '$emit').and.callThrough();
spyOn(toast, 'add').and.callThrough();
service.initScope($scope);
service.perform(image);
$scope.$apply();
$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(toast.add.calls.count()).toBe(1);
expect($scope.$emit)
.toHaveBeenCalledWith('horizon.app.core.images.UPDATE_SUCCESS', newImage);
});
it('should destroy the event watchers', function() {
testImage = { name: 'Test', id: '2' };
var newImage = { name: 'Test2', id: '2' };
var newMetadata = {p1: '11', p3: '3'};
spyOn(wizardModalService, 'modal').and.callThrough();
spyOn(glanceAPI, 'updateImage').and.callThrough();
spyOn(metadataService, 'editMetadata').and.callThrough();
spyOn(toast, 'add').and.callThrough();
service.initScope($scope);
service.perform(testImage);
$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.updateImage).toHaveBeenCalledWith(testImage);
expect(metadataService.editMetadata)
.toHaveBeenCalledWith('image', testImage.id, {}, ['p1', 'p2']);
expect(toast.add.calls.count()).toBe(2);
});
});
describe('edit', function() {
it('should allow edit if image can be edited', function() {
var image = {owner: 'project', status: 'active'};
var allowed = service.allowed(image);
permissionShouldPass(allowed);
$scope.$apply();
});
it('should not allow edit if image is not owned by user', function() {
deferred.reject();
var image = {owner: 'doesnt_matter', status: 'active'};
var allowed = service.allowed(image);
permissionShouldFail(allowed);
$scope.$apply();
});
it('should not allow edit if image status is not active', function() {
var image = {owner: 'project', status: 'not_active'};
var allowed = service.allowed(image);
permissionShouldFail(allowed);
$scope.$apply();
});
function permissionShouldFail(permissions) {
permissions.then(
function() {
expect(false).toBe(true);
},
function() {
expect(true).toBe(true);
});
}
function permissionShouldPass(permissions) {
permissions.then(
function() {
expect(true).toBe(true);
},
function() {
expect(false).toBe(true);
});
}
});
});
})();

View File

@ -0,0 +1,57 @@
/**
*
* 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.editWorkflow', editWorkflow);
editWorkflow.$inject = [
'horizon.app.core.images.basePath',
'horizon.app.core.workflow.factory',
'horizon.framework.util.i18n.gettext'
];
/**
* @ngdoc factory
* @name horizon.app.core.images.editWorkflow
* @description A workflow for the edit image action.
*/
function editWorkflow(basePath, workflowService, gettext) {
var workflow = workflowService({
title: gettext('Edit Image'),
btnText: { finish: gettext('Update Image') },
steps: [
{
title: gettext('Image Details'),
templateUrl: basePath + 'steps/edit-image/edit-image.html',
helpUrl: basePath + 'steps/edit-image/edit-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,54 @@
/**
*
* 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
* 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.editWorkflow', 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.editWorkflow');
}));
it('create the workflow for editing image', function() {
expect(service.title).toEqual('Edit Image');
var steps = service.steps;
expect(steps.length).toEqual(2);
expect(steps[0].templateUrl).toEqual('/dummy/steps/edit-image/edit-image.html');
expect(steps[1].templateUrl).toEqual('/dummy/steps/update-metadata/update-metadata.html');
});
});
})();

View File

@ -29,6 +29,8 @@
.module('horizon.app.core.images', ['ngRoute', 'horizon.app.core.images.actions'])
.constant('horizon.app.core.images.events', events())
.constant('horizon.app.core.images.non_bootable_image_types', ['aki', 'ari'])
.constant('horizon.app.core.images.validationRules', validationRules())
.constant('horizon.app.core.images.imageFormats', imageFormats())
.constant('horizon.app.core.images.resourceType', 'OS::Glance::Image')
.run(registerImageType)
.config(config);
@ -107,6 +109,39 @@
});
}
/**
* @ngdoc constant
* @name horizon.app.core.images.validationRules
* @description constants for use in validation fields
*/
function validationRules() {
return {
integer: /^[0-9]+$/,
fieldMaxLength: 255
};
}
/**
* @ngdoc constant
* @name horizon.app.core.images.imageFormats
* @description constants for list of image types in dropdowns
*/
function imageFormats() {
return {
iso: gettext('ISO - Optical Disk Image'),
ova: gettext('OVA - Open Virtual Appliance'),
qcow2: gettext('QCOW2 - QEMU Emulator'),
raw: gettext('Raw'),
vdi: gettext('VDI - Virtual Disk Image'),
vhd: gettext('VHD - Virtual Hard Disk'),
vmdk: gettext('VMDK - Virtual Machine Disk'),
aki: gettext('AKI - Amazon Kernel Image'),
ami: gettext('AMI - Amazon Machine Image'),
ari: gettext('ARI - Amazon Ramdisk Image'),
docker: gettext('Docker')
};
}
/**
* @ngdoc value
* @name horizon.app.core.images.events
@ -116,7 +151,10 @@
return {
DELETE_SUCCESS: 'horizon.app.core.images.DELETE_SUCCESS',
VOLUME_CHANGED: 'horizon.app.core.images.VOLUME_CHANGED',
UPDATE_METADATA_SUCCESS: 'horizon.app.core.images.UPDATE_METADATA_SUCCESS'
UPDATE_METADATA_SUCCESS: 'horizon.app.core.images.UPDATE_METADATA_SUCCESS',
UPDATE_SUCCESS: 'horizon.app.core.images.UPDATE_SUCCESS',
IMAGE_CHANGED: 'horizon.app.core.images.IMAGE_CHANGED',
IMAGE_METADATA_CHANGED: 'horizon.app.core.images.IMAGE_METADATA_CHANGED'
};
}

View File

@ -89,7 +89,19 @@
{ templateUrl: staticUrl + 'app/core/images/detail/image-detail.html'}
]);
});
});
describe('horizon.app.core.images.imageFormats constant', function() {
var imageFormats;
beforeEach(module('horizon.app.core.images'));
beforeEach(inject(function ($injector) {
imageFormats = $injector.get('horizon.app.core.images.imageFormats');
}));
it('should be defined', function() {
expect(Object.keys(imageFormats).length).toEqual(11);
});
});
})();

View File

@ -0,0 +1,120 @@
/**
*
* 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.EditImageController', EditImageController);
EditImageController.$inject = [
'$scope',
'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.EditImageController
* @description
* This controller is use for updating an image.
*/
function EditImageController(
$scope,
events,
imageFormats,
validationRules,
settings
) {
var ctrl = this;
settings.getSettings().then(getConfiguredFormats);
ctrl.diskFormats = [];
ctrl.validationRules = validationRules;
ctrl.imageProtectedOptions = [
{ label: gettext('Yes'), value: true },
{ label: gettext('No'), value: false }
];
ctrl.imageVisibilityOptions = [
{ label: gettext('Public'), value: 'public'},
{ label: gettext('Private'), value: 'private' }
];
ctrl.setFormats = setFormats;
$scope.imagePromise.then(init);
var imageChangedWatcher;
$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;
}
function init(response) {
ctrl.image = response.data;
ctrl.image.kernel = ctrl.image.properties.kernel_id;
ctrl.image.ramdisk = ctrl.image.properties.ramdisk_id;
ctrl.image.architecture = ctrl.image.properties.architecture;
ctrl.image.visibility = ctrl.image.is_public ? 'public' : 'private';
ctrl.image_format = ctrl.image.disk_format;
if (ctrl.image.container_format === 'docker') {
ctrl.image_format = 'docker';
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() {
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,169 @@
/**
* (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 edit image controller', function() {
var controller, $scope, events, $q, settingsCall, $timeout;
///////////////////////
beforeEach(module('horizon.framework'));
beforeEach(module('horizon.app.core'));
beforeEach(module('horizon.app.core.images'));
beforeEach(inject(function ($injector, _$rootScope_, _$q_, _$timeout_) {
$scope = _$rootScope_.$new();
$q = _$q_;
$timeout = _$timeout_;
events = $injector.get('horizon.app.core.images.events');
controller = $injector.get('$controller');
}));
function createController() {
var settings = {
getSettings: function() {
settingsCall = $q.defer();
return settingsCall.promise;
}
};
return controller('horizon.app.core.images.steps.EditImageController as ctrl', {
$scope: $scope,
events: events,
'horizon.app.core.openstack-service-api.settings': settings
});
}
function setImagePromise(image) {
var deferred = $q.defer();
deferred.resolve({data: image});
$scope.imagePromise = deferred.promise;
}
it('should have options for visibility and protected', function() {
setImagePromise({id: '1', container_format: 'bare', is_public: false, properties: []});
var ctrl = createController();
expect(ctrl.imageVisibilityOptions.length).toEqual(2);
expect(ctrl.imageProtectedOptions.length).toEqual(2);
});
it('should map is_public', function() {
setImagePromise({id: '1', container_format: 'bare', is_public: false, properties: []});
var ctrl = createController();
$timeout.flush();
expect(ctrl.image.visibility).toEqual('private');
});
it('reads the data format settings', function() {
setImagePromise({id: '1', container_format: 'bare', is_public: false, properties: []});
var ctrl = createController();
settingsCall.resolve({OPENSTACK_IMAGE_FORMATS: ['aki', 'ami']});
$timeout.flush();
expect(ctrl.imageFormats.aki).toBeDefined();
expect(ctrl.imageFormats.ami).toBeDefined();
expect(Object.keys(ctrl.imageFormats).length).toBe(2);
});
it('should initialize image values correctly', function() {
setImagePromise({
id: '1',
container_format: 'bare',
disk_format: 'ova',
is_public: true,
properties: []
});
var ctrl = createController();
$timeout.flush();
expect(ctrl.image.disk_format).toEqual('ova');
expect(ctrl.image.visibility).toEqual('public');
});
it('should set local image_format to docker when container is docker', function() {
setImagePromise({id: '1', disk_format: 'raw', container_format: 'docker',
is_public: false, properties: []});
var ctrl = createController();
$timeout.flush();
expect(ctrl.image_format).toEqual('docker');
});
it('should set container to aki when disk format is same', function() {
setImagePromise({id: '1', disk_format: 'aki', container_format: '',
is_public: false, properties: []});
var ctrl = createController();
$timeout.flush();
expect(ctrl.image.container_format).toEqual('aki');
});
it('should set container to ami when disk format is same', function() {
setImagePromise({id: '1', disk_format: 'ami', container_format: '',
is_public: false, properties: []});
var ctrl = createController();
$timeout.flush();
expect(ctrl.image.container_format).toEqual('ami');
});
it('should set container to ari when disk format is same', function() {
setImagePromise({id: '1', disk_format: 'ari', container_format: '',
is_public: false, properties: []});
var ctrl = createController();
$timeout.flush();
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();
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();
});
});
})();

View File

@ -0,0 +1,4 @@
<div>
<h3 translate>Description</h3>
<p translate>Use this page to edit information about the image.</p>
</div>

View File

@ -0,0 +1,147 @@
<div ng-controller="horizon.app.core.images.steps.EditImageController as ctrl">
<div class="content">
<h3 translate>Image Detail</h3>
<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</label>
<input required
id="imageForm-name" name="name"
type="text" class="form-control"
ng-model="ctrl.image.name"
ng-maxlength="ctrl.validationRules.fieldMaxLength"
placeholder="{$ 'Enter an Image name'|translate $}">
<p class="help-block"
ng-show="imageForm.name.$invalid && imageForm.name.$dirty">
<translate>An image name less than 256 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</label>
<input id="imageForm-description" name="description"
type="text" class="form-control"
ng-model="ctrl.image.properties.description"
ng-maxlength="ctrl.validationRules.fieldMaxLength"
placeholder="{$ 'Enter an Image Description'|translate $}">
<p class="help-block alert alert-danger"
ng-show="imageForm.description.$invalid && imageForm.description.$dirty">
<translate>An image description less than 256 characters is required.</translate>
</p>
</div>
</div>
</div>
</div>
<div class="selected-source clearfix">
<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-format" translate>Format</label>
<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 translate>Image Requirements</h3>
<div class="subtitle"></div>
<div class="selected-source clearfix">
<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 ID</label>
<input id="imageForm-kernel" name="kernel"
type="text" class="form-control"
readonly="readonly"
ng-model="ctrl.image.kernel">
</div>
</div>
<div class="col-xs-6 col-sm-6">
<div class="form-group">
<label class="control-label" for="imageForm-ramdisk" translate>Ramdisk ID</label>
<input id="imageForm-ramdisk" name="ramdisk"
type="text" class="form-control"
readonly="readonly"
ng-model="ctrl.image.ramdisk">
</div>
</div>
<div class="col-xs-6 col-sm-6">
<div class="form-group">
<label class="control-label" for="imageForm-architecture" translate>Architecture</label>
<input id="imageForm-architecture" name="architecture"
type="text" class="form-control"
ng-model="ctrl.image.architecture"
readonly="readonly">
</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)</label>
<input id="imageForm-min_disk" name="min_disk"
type="number" class="form-control"
ng-pattern="ctrl.validationRules.integer" ng-model="ctrl.image.min_disk"
placeholder="{$ 'The minimum disk size required to boot the image. If unspecified, this value defaults to 0 (no minimum).'|translate $}">
</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)</label>
<input id="imageForm-min_ram" name="min_ram"
type="number" class="form-control"
ng-pattern="ctrl.validationRules.integer" ng-model="ctrl.image.min_ram"
placeholder="{$ 'The minimum memory size required to boot the image. If unspecified, this value defaults to 0 (no minimum).'|translate $}">
</div>
</div>
</div>
</div>
<h3 translate>Image Sharing</h3>
<div class="subtitle"></div>
<div class="selected-source clearfix">
<div class="row">
<div class="col-xs-6 col-sm-6">
<div class="form-group">
<label class="control-label required" translate>Visibility</label>
<div class="form-field">
<div class="btn-group">
<button class="btn btn-toggle"
ng-repeat="option in ctrl.imageVisibilityOptions"
ng-model="ctrl.image.visibility"
btn-radio="option.value">{$ ::option.label $}</button>
</div>
</div>
</div>
</div>
<div class="col-xs-6 col-sm-6">
<div class="form-group">
<label class="control-label required" translate>Protected</label>
<div class="form-field">
<div class="btn-group">
<button class="btn btn-toggle"
ng-repeat="option in ctrl.imageProtectedOptions"
ng-model="ctrl.image.protected"
btn-radio="option.value">{$ ::option.label $}</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,80 @@
/*
* 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.UpdateMetadataController', UpdateMetadataController);
UpdateMetadataController.$inject = [
'$scope',
'$q',
'horizon.app.core.images.events',
'horizon.app.core.metadata.service',
'horizon.framework.widgets.metadata.tree.service'
];
/**
* @ngdoc controller
* @name MetadataController
* @description
* Controller used by Image for Update Metadata
*/
function UpdateMetadataController(
$scope,
$q,
events,
metadataService,
metadataTreeService
) {
var ctrl = this;
ctrl.tree = new metadataTreeService.Tree([], []);
/* eslint-enable angular/ng_controller_as */
$scope.$watchCollection(getTree, onMetadataChanged);
/* eslint-enable angular/ng_controller_as */
$scope.imagePromise.then(init);
////////////////////////////////
function init(response) {
var image = response.data;
$q.all({
available: metadataService.getNamespaces('image'),
existing: metadataService.getMetadata('image', image.id)
}).then(onMetadataGet);
}
function onMetadataGet(response) {
ctrl.tree = new metadataTreeService.Tree(
response.available.data.items,
response.existing.data
);
}
function getTree() {
return ctrl.tree.getExisting();
}
function onMetadataChanged(newValue, oldValue) {
if (newValue !== oldValue) {
$scope.$emit(events.IMAGE_METADATA_CHANGED, newValue);
}
}
}
})();

View File

@ -0,0 +1,123 @@
/*
*
* 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', function () {
describe('controller.UpdateMetadataController', function () {
var mockTree = {
getExisting: function() {}
};
var availableMetadata = {id: 'availableMetadata'};
var existingMetadata = {id: 'existingMetadata'};
var metadataService = {
getNamespaces: function() {
return {
then: function(callback) {
callback({
data: {
items: availableMetadata
}
});
}
};
},
getMetadata: function() {
return {
then: function(callback) {
callback({
data: existingMetadata
});
}
};
}
};
var metadataTreeService = {
Tree: function() {}
};
var $controller, $scope, $q;
beforeEach(module('horizon.framework'));
beforeEach(module('horizon.framework.widgets'));
beforeEach(module('horizon.framework.widgets.metadata.tree'));
beforeEach(module('horizon.app.core.images'));
beforeEach(module(function($provide) {
$provide.value('horizon.framework.widgets.metadata.tree.service', metadataTreeService);
$provide.value('horizon.app.core.metadata.service', metadataService);
}));
beforeEach(inject(function($injector, _$rootScope_, _$q_) {
$controller = $injector.get('$controller');
$scope = _$rootScope_.$new();
$q = _$q_;
}));
it('should setup up the metadata tree', function() {
var deferred = $q.defer();
deferred.resolve({data: {id: '1'}});
$scope.imagePromise = deferred.promise;
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([], []);
expect(metadataTreeService.Tree).toHaveBeenCalledWith(availableMetadata, existingMetadata);
});
it('should emit imageMetadataChanged event when metadata changes', function() {
var deferred = $q.defer();
deferred.resolve({data: {id: '1'}});
$scope.imagePromise = deferred.promise;
spyOn(metadataTreeService, 'Tree').and.returnValue(mockTree);
createController();
spyOn($scope, '$emit').and.callThrough();
var mockGetExisting = spyOn(mockTree, 'getExisting');
mockGetExisting.and.returnValue('1');
$scope.$apply();
mockGetExisting.and.returnValue('2');
$scope.$apply();
expect($scope.$emit).toHaveBeenCalledWith(
'horizon.app.core.images.IMAGE_METADATA_CHANGED',
'2'
);
});
function createController() {
return $controller('horizon.app.core.images.steps.UpdateMetadataController', {
'$scope': $scope,
'metadataService': metadataService,
'metadataTreeService': metadataTreeService
});
}
});
});
})();

View File

@ -0,0 +1,16 @@
<div>
<h1 translate>Metadata Help</h1>
<p translate>You can add arbitrary metadata to your image.</p>
<p translate>
Metadata is used to provide additional information about the
image. Sometimes this information is only used for sorting and viewing.
In some installations this information may affect how the instance is
deployed or behaves.
</p>
<p translate>
Metadata is a collection of key-value pairs associated with an instance.
The maximum length for each metadata key and value is 255 characters.
</p>
</div>

View File

@ -0,0 +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>
</div>
</div>

View File

@ -65,6 +65,7 @@
ctrl.imageResourceType = typeRegistry.getResourceType(imageResourceType);
var deleteWatcher = $scope.$on(events.DELETE_SUCCESS, onDeleteSuccess);
var updateWatcher = $scope.$on(events.UPDATE_SUCCESS, onUpdateSuccess);
$scope.$on('$destroy', destroy);
@ -95,6 +96,12 @@
applyMetadataDefinitions();
}
function onUpdateSuccess(e, image) {
e.stopPropagation();
ctrl.imagesSrc = difference(ctrl.imagesSrc, [image.id], 'id');
ctrl.imagesSrc.push(image);
}
function onDeleteSuccess(e, removedImageIds) {
ctrl.imagesSrc = difference(ctrl.imagesSrc, removedImageIds, 'id');
e.stopPropagation();
@ -115,6 +122,7 @@
}
function destroy() {
updateWatcher();
deleteWatcher();
}

View File

@ -18,15 +18,20 @@
'use strict';
describe('horizon.app.core.images table controller', function() {
var images = [{id: '1', visibility: 'public', filtered_visibility: 'Public'},
{id: '2', is_public: false, owner: 'not_me', filtered_visibility: 'Shared with Me'}];
var glanceAPI = {
getImages: function () {
return {
data: {
items: [
{id: '1', visibility: 'public'},
{id: '2', is_public: false, owner: 'not_me'}
{id: '1', visibility: 'public', filtered_visibility: 'Public'},
{id: '2', is_public: false, owner: 'not_me', filtered_visibility: 'Shared with Me'}
]
},
success: function(callback) {
callback({items : angular.copy(images)});
}
};
},
@ -133,7 +138,16 @@
expect($scope.$emit).toHaveBeenCalledWith('hzTable:clearSelected');
});
it('should destroy the event watchers', function() {
it('should refresh images after update', function() {
var ctrl = createController();
expect(ctrl.imagesSrc).toEqual(images);
$scope.$emit(events.UPDATE_SUCCESS, {id: '1', name: 'name_new'});
expect(ctrl.imagesSrc.filter(function (x) { return x.id === '1'; })[0].name).toBe('name_new');
});
it('should destroy the event watcher for delete', function() {
var ctrl = createController();
$scope.$emit('$destroy');
@ -145,5 +159,23 @@
]);
});
it('should destroy the event watcher for update', function() {
var ctrl = createController();
$scope.$emit('$destroy');
$scope.$emit(events.UPDATE_SUCCESS, {id: '1', name: 'name_new'});
expect(ctrl.imagesSrc).toEqual(images);
});
it('should destroy the event watcher for create', function() {
var ctrl = createController();
$scope.$emit('$destroy');
$scope.$emit(events.createSuccess, {id: '3'});
expect(ctrl.imagesSrc).toEqual(images);
});
});
})();