Final changes to Angular Images before making default

This addresses the suggestions found by Rob and Travis:

- The Admin panel doesn't show *all* images, just those either in the current
  project or exposed as public (same as Project). (Travis)

  FIX: The Admin panel now supplies is_public: None as the Python code does,
  which allows all images to be shown.

- The 'Is Public' attribute is in the drawer now (even on large screen sizes)
and we can't sort by shared, public etc, as far as I can tell. I don't think
we specifically need the buttons like the previous design had, but it feels
like a regression to have no filtering or sorting on that field? Perhaps
someone with better glance knowledge could correct me.

  FIX: Added 'visibility' as a synthesized field and added as a facet, making
  sure to use the current project to correctly identify 'Shared with Project'.
  Did *not* add 'Is Public' as that is a vestigial concept.

- Create/Edit Image > Image Details tab needs help text. Create has some info,
but Edit has nothing really. Also, we can drop that "description" header in
the help text; it doesnt add any value. This would be a nice improvement over
the Python version for novice users, and shouldnt take long to enter - just
pull info from the API docs.

  NO CHANGE: Let's not block enablement on verbiage. I think we need a better
  consistency and I don't believe the last-minute is the best time to add this
  because it's very easy to create/find nits.

- Create Volume help text is also entirely blank. We should fill that out
with some useful instructions, IMO.

  FIX: Added basic help text.

- Create Volume should default to the first Availability Zone if there is
only one, as it is a required field anyway.

  FIX: I added logic for this, and also added in some test features so we don't
  lose coverage.

- The Image Details page now longer lists the owner ID. I don't think this
is addressed by Travis' patch either.

  FIX: This is now under the 'Security' heading.

- Kernel ID and Ramdisk ID seem empty in angular image details, but populated
in the python equivalent.

  FIX: This was a regression that this patch fixes.

- Why are created/updated/ID separated into "Record Properties"? Is there
something in the API about this, or is it just a presentation construct?

  CHANGE: Moved into the general 'Image' header.  Added a filter so that these
  can be implemented as basic property registrations.

- I think 'Filename' in angular image details can be hidden if empty.

  CHANGE: Instead of hiding, we can show the standard '-' if no filename.

Change-Id: I4b770f9e61f9a8b1bd735d95c2ccc75bc21dd944
Partially-Implements: blueprint angularize-images-table
This commit is contained in:
Matt Borland 2016-08-17 07:38:56 -06:00
parent d1498101ea
commit 2289190653
16 changed files with 180 additions and 56 deletions

View File

@ -19,6 +19,7 @@
angular angular
.module('horizon.framework.util.filters') .module('horizon.framework.util.filters')
.filter('yesno', yesNoFilter) .filter('yesno', yesNoFilter)
.filter('simpleDate', simpleDateFilter)
.filter('gb', gbFilter) .filter('gb', gbFilter)
.filter('mb', mbFilter) .filter('mb', mbFilter)
.filter('title', titleFilter) .filter('title', titleFilter)
@ -45,6 +46,19 @@
}; };
} }
/**
* @ngdoc filter
* @name simpleDate
* @description
* Evaluates given for display as a short date, returning '-' if empty.
*/
simpleDateFilter.$inject = ['$filter'];
function simpleDateFilter($filter) {
return function (input) {
return $filter('noValue')($filter('date')(input, 'short'));
};
}
/** /**
* @ngdoc filter * @ngdoc filter
* @name gb * @name gb

View File

@ -38,6 +38,21 @@
}); });
}); });
describe('simpleDate', function () {
var simpleDateFilter;
beforeEach(inject(function (_simpleDateFilter_) {
simpleDateFilter = _simpleDateFilter_;
}));
it('returns blank if nothing', function () {
expect(simpleDateFilter()).toBe('-');
});
it('returns the expected time', function() {
expect(simpleDateFilter('2016-06-24T04:19:07')).toBe('6/24/16 4:19 AM');
});
});
describe('gb', function () { describe('gb', function () {
var gbFilter; var gbFilter;
beforeEach(inject(function (_gbFilter_) { beforeEach(inject(function (_gbFilter_) {

View File

@ -0,0 +1,5 @@
<hz-resource-panel resource-type-name="OS::Glance::Image">
<hz-resource-table resource-type-name="OS::Glance::Image"
track-by="trackBy"
list-function-extra-params="{is_public: 'None'}"></hz-resource-table>
</hz-resource-panel>

View File

@ -30,27 +30,23 @@
registerImageDetails.$inject = [ registerImageDetails.$inject = [
'horizon.app.core.images.basePath', 'horizon.app.core.images.basePath',
'horizon.app.core.images.resourceType', 'horizon.app.core.images.resourceType',
'horizon.app.core.openstack-service-api.glance', 'horizon.app.core.images.service',
'horizon.framework.conf.resource-type-registry.service' 'horizon.framework.conf.resource-type-registry.service'
]; ];
function registerImageDetails( function registerImageDetails(
basePath, basePath,
imageResourceType, imageResourceType,
glanceApi, imageService,
registry registry
) { ) {
registry.getResourceType(imageResourceType) registry.getResourceType(imageResourceType)
.setLoadFunction(loadFunction) .setLoadFunction(imageService.getImagePromise)
.detailsViews.append({ .detailsViews.append({
id: 'imageDetailsOverview', id: 'imageDetailsOverview',
name: gettext('Overview'), name: gettext('Overview'),
template: basePath + 'details/overview.html' template: basePath + 'details/overview.html'
}); });
function loadFunction(identifier) {
return glanceApi.getImage(identifier);
}
} }
})(); })();

View File

@ -5,7 +5,7 @@
item="item" item="item"
property-groups="[ property-groups="[
['name', 'id'], ['name', 'id'],
['is_public', 'protected'], ['visibility', 'protected'],
['disk_format', 'size'], ['disk_format', 'size'],
['min_disk', 'min_ram']]"> ['min_disk', 'min_ram']]">
</hz-resource-property-list> </hz-resource-property-list>

View File

@ -23,12 +23,14 @@
ImageOverviewController.$inject = [ ImageOverviewController.$inject = [
'horizon.app.core.images.resourceType', 'horizon.app.core.images.resourceType',
'horizon.framework.conf.resource-type-registry.service', 'horizon.framework.conf.resource-type-registry.service',
'horizon.app.core.openstack-service-api.userSession',
'$scope' '$scope'
]; ];
function ImageOverviewController( function ImageOverviewController(
imageResourceTypeCode, imageResourceTypeCode,
registry, registry,
userSession,
$scope $scope
) { ) {
var ctrl = this; var ctrl = this;
@ -44,6 +46,12 @@
ctrl.image.properties = Object.keys(ctrl.image.properties).map(function mapProps(prop) { ctrl.image.properties = Object.keys(ctrl.image.properties).map(function mapProps(prop) {
return {name: prop, value: ctrl.image.properties[prop]}; return {name: prop, value: ctrl.image.properties[prop]};
}); });
userSession.get().then(setProject);
function setProject(session) {
ctrl.projectId = session.project_id;
}
} }
} }

View File

@ -25,10 +25,14 @@
beforeEach(module('horizon.app.core.images')); beforeEach(module('horizon.app.core.images'));
beforeEach(module('horizon.framework.conf')); beforeEach(module('horizon.framework.conf'));
beforeEach(inject(function($controller, $q) { beforeEach(inject(function($controller, $q, $injector) {
var session = $injector.get('horizon.app.core.openstack-service-api.userSession');
var deferred = $q.defer(); var deferred = $q.defer();
var sessionDeferred = $q.defer();
deferred.resolve({data: {properties: {'a': 'apple'}}}); deferred.resolve({data: {properties: {'a': 'apple'}}});
deferred.resolve({project_id: '12'});
spyOn(glance, 'getNamespaces').and.returnValue(deferred.promise); spyOn(glance, 'getNamespaces').and.returnValue(deferred.promise);
spyOn(session, 'get').and.returnValue(sessionDeferred.promise);
ctrl = $controller('ImageOverviewController', ctrl = $controller('ImageOverviewController',
{ {
'$scope': {context: {loadPromise: deferred.promise}} '$scope': {context: {loadPromise: deferred.promise}}

View File

@ -8,45 +8,35 @@
cls="dl-horizontal" cls="dl-horizontal"
item="ctrl.image" item="ctrl.image"
property-groups="[[ property-groups="[[
'type', 'status', 'size', 'min_disk', 'min_ram', 'disk_format', 'id', 'type', 'status', 'size', 'min_disk', 'min_ram', 'disk_format',
'container_format']]"> 'container_format', 'created_at', 'updated_at']]">
</hz-resource-property-list> </hz-resource-property-list>
</div> </div>
<div class="col-md-6 detail"> <div class="col-md-6 detail">
<h3>{$ 'Security' | translate $}</h3> <h3>{$ 'Security' | translate $}</h3>
<hr> <hr>
<dl class="dl-horizontal"> <dl class="dl-horizontal">
<dt translate>Owner</dt>
<dd>{$ ctrl.image.owner $}</dd>
<dt translate>Filename</dt> <dt translate>Filename</dt>
<dd>{$ ctrl.image.properties.filename $}</dd> <dd>{$ ctrl.image.properties.filename | noValue $}</dd>
<dt translate>Visibility</dt> <dt translate>Visibility</dt>
<dd>{$ ctrl.image | imageVisibility $}</dd> <dd>{$ ctrl.image | imageVisibility:ctrl.projectId $}</dd>
<dt translate>Protected</dt> <dt translate>Protected</dt>
<dd>{$ ctrl.image.protected | yesno $}</dd> <dd>{$ ctrl.image.protected | yesno $}</dd>
<dt translate>Checksum</dt> <dt translate>Checksum</dt>
<dd>{$ ctrl.image.checksum $}</dd> <dd>{$ ctrl.image.checksum | noValue $}</dd>
</dl> </dl>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6 detail">
<h3 translate>Record Properties</h3>
<hr>
<dl class="dl-horizontal">
<dt translate>Created</dt>
<dd>{$ ctrl.image.created_at | date:'short' $}</dd>
<dt translate>Updated</dt>
<dd>{$ ctrl.image.updated_at | date:'short' $}</dd>
<dt translate>ID</dt>
<dd>{$ ctrl.image.id $}</dd>
</dl>
</div>
<div class="col-md-6 detail"> <div class="col-md-6 detail">
<h3 translate>Custom Properties</h3> <h3 translate>Custom Properties</h3>
<hr> <hr>
<dl class="dl-horizontal"> <dl class="dl-horizontal">
<div ng-repeat="prop in ctrl.image.properties"> <div ng-repeat="prop in ctrl.image.properties">
<dt data-toggle="tooltip" title="{$ prop.name $}">{$ ctrl.resourceType.label(prop.name) $}</dt> <dt data-toggle="tooltip" title="{$ prop.name $}">{$ ctrl.resourceType.label(prop.name) $}</dt>
<dd>{$ ctrl.resourceType.format(prop.name, prop.value) $}</dd> <dd>{$ prop.value $}</dd>
</div> </div>
</dl> </dl>
</div> </div>

View File

@ -81,7 +81,8 @@
filters: ['uppercase'] filters: ['uppercase']
}) })
.setProperty('created_at', { .setProperty('created_at', {
label: gettext('Created At') label: gettext('Created At'),
filters: ['simpleDate']
}) })
.setProperty('disk_format', { .setProperty('disk_format', {
label: gettext('Disk Format'), label: gettext('Disk Format'),
@ -90,10 +91,6 @@
.setProperty('id', { .setProperty('id', {
label: gettext('ID') label: gettext('ID')
}) })
.setProperty('is_public', {
label: gettext('Is Public'),
filters: ['yesno']
})
.setProperty('type', { .setProperty('type', {
label: gettext('Type'), label: gettext('Type'),
filters: [imagesService.imageType] filters: [imagesService.imageType]
@ -129,7 +126,8 @@
label: gettext('Tags') label: gettext('Tags')
}) })
.setProperty('updated_at', { .setProperty('updated_at', {
label: gettext('Updated At') label: gettext('Updated At'),
filters: ['simpleDate']
}) })
.setProperty('virtual_size', { .setProperty('virtual_size', {
label: gettext('Virtual Size') label: gettext('Virtual Size')
@ -172,6 +170,10 @@
priority: 1, priority: 1,
itemInTransitionFunction: imagesService.isInTransition itemInTransitionFunction: imagesService.isInTransition
}) })
.append({
id: 'visibility',
priority: 1
})
.append({ .append({
id: 'protected', id: 'protected',
priority: 1 priority: 1
@ -208,6 +210,18 @@
{label: gettext('Deleted'), key: 'deleted'} {label: gettext('Deleted'), key: 'deleted'}
] ]
}) })
.append({
label: gettext('Visibility'),
name: 'visibility',
isServer: false,
singleton: true,
options: [
{label: gettext('Public'), key: gettext('Public')},
{label: gettext('Private'), key: gettext('Private')},
{label: gettext('Shared With Project'), key: gettext('Shared With Project')},
{label: gettext('Unknown'), key: 'unknown'}
]
})
.append({ .append({
label: gettext('Protected'), label: gettext('Protected'),
name: 'protected', name: 'protected',
@ -330,7 +344,7 @@
}); });
$routeProvider.when('/admin/images/', { $routeProvider.when('/admin/images/', {
templateUrl: path + 'panel.html' templateUrl: path + 'admin-panel.html'
}); });
function goToAngularDetails(params) { function goToAngularDetails(params) {

View File

@ -20,7 +20,9 @@
.factory('horizon.app.core.images.service', imageService); .factory('horizon.app.core.images.service', imageService);
imageService.$inject = [ imageService.$inject = [
'$filter',
'horizon.app.core.openstack-service-api.glance', 'horizon.app.core.openstack-service-api.glance',
'horizon.app.core.openstack-service-api.userSession',
'horizon.app.core.images.transitional-statuses' 'horizon.app.core.images.transitional-statuses'
]; ];
@ -34,9 +36,10 @@
* but do not need to be restricted to such use. Each exposed function * but do not need to be restricted to such use. Each exposed function
* is documented below. * is documented below.
*/ */
function imageService(glance, transitionalStatuses) { function imageService($filter, glance, userSession, transitionalStatuses) {
return { return {
getDetailsPath: getDetailsPath, getDetailsPath: getDetailsPath,
getImagePromise: getImagePromise,
getImagesPromise: getImagesPromise, getImagesPromise: getImagesPromise,
imageType: imageType, imageType: imageType,
isInTransition: isInTransition isInTransition: isInTransition
@ -99,17 +102,34 @@
* 'trackBy' to assist the display mechanism when updating rows. * 'trackBy' to assist the display mechanism when updating rows.
*/ */
function getImagesPromise(params) { function getImagesPromise(params) {
return glance.getImages(params).then(modifyResponse); var projectId;
return userSession.get().then(getImages);
function getImages(userSession) {
projectId = userSession.project_id;
return glance.getImages(params).then(modifyResponse);
}
function modifyResponse(response) { function modifyResponse(response) {
return {data: {items: response.data.items.map(addTrackBy)}}; return {data: {items: response.data.items.map(modifyImage)}};
function addTrackBy(image) { function modifyImage(image) {
image.trackBy = image.id + image.updated_at; image.trackBy = image.id + image.updated_at;
image.visibility = $filter('imageVisibility')(image, projectId);
return image; return image;
} }
} }
} }
/*
* @ngdoc function
* @name getImagePromise
* @description
* Given an id, returns a promise for the image data.
*/
function getImagePromise(identifier) {
return glance.getImage(identifier);
}
} }
})(); })();

View File

@ -86,14 +86,30 @@
describe('getImagesPromise', function() { describe('getImagesPromise', function() {
it("provides a promise that gets translated", inject(function($q, $injector, $timeout) { it("provides a promise that gets translated", inject(function($q, $injector, $timeout) {
var glance = $injector.get('horizon.app.core.openstack-service-api.glance'); var glance = $injector.get('horizon.app.core.openstack-service-api.glance');
var session = $injector.get('horizon.app.core.openstack-service-api.userSession');
var deferred = $q.defer(); var deferred = $q.defer();
var deferredSession = $q.defer();
spyOn(glance, 'getImages').and.returnValue(deferred.promise); spyOn(glance, 'getImages').and.returnValue(deferred.promise);
spyOn(session, 'get').and.returnValue(deferredSession.promise);
var result = service.getImagesPromise({}); var result = service.getImagesPromise({});
deferred.resolve({data: {items: [{id: 1, updated_at: 'jul1'}]}}); deferred.resolve({data: {items: [{id: 1, updated_at: 'jul1'}]}});
deferredSession.resolve({project_id: '12'});
$timeout.flush(); $timeout.flush();
expect(result.$$state.value.data.items[0].trackBy).toBe('1jul1'); expect(result.$$state.value.data.items[0].trackBy).toBe('1jul1');
})); }));
}); });
describe('getImagePromise', function() {
it("provides a promise", inject(function($q, $injector) {
var glance = $injector.get('horizon.app.core.openstack-service-api.glance');
var deferred = $q.defer();
spyOn(glance, 'getImage').and.returnValue(deferred.promise);
var result = service.getImagePromise({});
deferred.resolve({id: 1, updated_at: 'jul1'});
expect(glance.getImage).toHaveBeenCalled();
expect(result.$$state.value.updated_at).toBe('jul1');
}));
});
}); });
})(); })();

View File

@ -155,7 +155,14 @@
} }
function onGetAvailabilityZones(response) { function onGetAvailabilityZones(response) {
ctrl.availabilityZones = response.items; ctrl.availabilityZones = response.items.map(justNames);
if (ctrl.availabilityZones.length > 0) {
ctrl.volume.availability_zone = ctrl.availabilityZones[0];
}
function justNames(item) {
return item.zoneName;
}
} }
function onGetAbsoluteLimits(response) { function onGetAbsoluteLimits(response) {

View File

@ -17,7 +17,7 @@
describe('horizon.app.core.images.controller.CreateVolumeController', function () { describe('horizon.app.core.images.controller.CreateVolumeController', function () {
var controller, quotaChartDefaults, $scope, $filter, getAbsoluteLimitsSpy; var controller, quotaChartDefaults, $scope, $filter, getAbsoluteLimitsSpy, nova;
var cinder = { var cinder = {
getVolumeTypes: function() { getVolumeTypes: function() {
@ -39,21 +39,21 @@
} }
}; };
var nova = {
getAvailabilityZones: function() {
return {
success: function(callback) {
return callback({ items: ['zone1'] });
}
};
}
};
beforeEach(module('horizon.app.core.images')); beforeEach(module('horizon.app.core.images'));
beforeEach(module('horizon.framework.widgets.charts')); beforeEach(module('horizon.framework.widgets.charts'));
beforeEach(module('horizon.framework.util.filters')); beforeEach(module('horizon.framework.util.filters'));
beforeEach(inject(function ($injector, _$rootScope_, _$filter_) { beforeEach(inject(function ($injector, _$rootScope_, _$filter_) {
nova = {
getAvailabilityZones: function() {
return {
success: function(callback) {
return callback({ items: [{zoneName: 'zone1'}] });
}
};
}
};
$scope = _$rootScope_.$new(); $scope = _$rootScope_.$new();
$scope.image = { $scope.image = {
name: 'ImageName', name: 'ImageName',
@ -364,6 +364,37 @@
ctrl.volume.size = 100; ctrl.volume.size = 100;
var emittedEventArgs = $scope.$emit.calls.argsFor(0);
var expectedVolume = {
size: 100,
name: ctrl.image.name,
description: '',
volume_type: 'lvmdriver-1',
availability_zone: 'zone1', // pre-selects first
metadata: {},
image_id: ctrl.image.id,
snapshot_id: null,
source_volid: null
};
expect(emittedEventArgs[0]).toEqual('horizon.app.core.images.VOLUME_CHANGED');
expect(emittedEventArgs[1]).toEqual(expectedVolume);
});
it('not default the availability_zone if none present', function() {
nova.getAvailabilityZones = function() {
return {
success: function(callback) {
return callback({ items: [] });
}
};
};
var ctrl = createController();
$scope.$apply();
ctrl.volume.size = 100;
var emittedEventArgs = $scope.$emit.calls.argsFor(0); var emittedEventArgs = $scope.$emit.calls.argsFor(0);
var expectedVolume = { var expectedVolume = {
size: 100, size: 100,

View File

@ -0,0 +1,4 @@
<div>
<h3 translate>Description</h3>
<p translate>This page allows you to create a volume based off of the image.</p>
</div>

View File

@ -49,12 +49,11 @@
<span translate>Availability Zone</span> <span translate>Availability Zone</span>
<span class="hz-icon-required fa fa-asterisk"></span> <span class="hz-icon-required fa fa-asterisk"></span>
</label> </label>
<select class="form-control" id="availability-zone" ng-model="createVolumeCtrl.volume.availability_zone" ng-required="true"> <select ng-options="item for item in createVolumeCtrl.availabilityZones"
<option value="" selected="selected" translate>Select a zone</option> class="form-control"
<option ng-repeat="availabilityZone in createVolumeCtrl.availabilityZones" id="availability-zone"
ng-value="availabilityZone.zoneName"> ng-model="createVolumeCtrl.volume.availability_zone"
{$ availabilityZone.zoneName $} ng-required="true">
</option>
</select> </select>
</div> </div>
</div> </div>

View File

@ -43,6 +43,7 @@
{ {
title: gettext('Volume Details'), title: gettext('Volume Details'),
templateUrl: basePath + 'steps/create-volume/create-volume.html', templateUrl: basePath + 'steps/create-volume/create-volume.html',
helpUrl: basePath + 'steps/create-volume/create-volume.help.html',
formName: 'volumeForm' formName: 'volumeForm'
} }
] ]