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
.module('horizon.framework.util.filters')
.filter('yesno', yesNoFilter)
.filter('simpleDate', simpleDateFilter)
.filter('gb', gbFilter)
.filter('mb', mbFilter)
.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
* @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 () {
var 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 = [
'horizon.app.core.images.basePath',
'horizon.app.core.images.resourceType',
'horizon.app.core.openstack-service-api.glance',
'horizon.app.core.images.service',
'horizon.framework.conf.resource-type-registry.service'
];
function registerImageDetails(
basePath,
imageResourceType,
glanceApi,
imageService,
registry
) {
registry.getResourceType(imageResourceType)
.setLoadFunction(loadFunction)
.setLoadFunction(imageService.getImagePromise)
.detailsViews.append({
id: 'imageDetailsOverview',
name: gettext('Overview'),
template: basePath + 'details/overview.html'
});
function loadFunction(identifier) {
return glanceApi.getImage(identifier);
}
}
})();

View File

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

View File

@ -23,12 +23,14 @@
ImageOverviewController.$inject = [
'horizon.app.core.images.resourceType',
'horizon.framework.conf.resource-type-registry.service',
'horizon.app.core.openstack-service-api.userSession',
'$scope'
];
function ImageOverviewController(
imageResourceTypeCode,
registry,
userSession,
$scope
) {
var ctrl = this;
@ -44,6 +46,12 @@
ctrl.image.properties = Object.keys(ctrl.image.properties).map(function mapProps(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.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 sessionDeferred = $q.defer();
deferred.resolve({data: {properties: {'a': 'apple'}}});
deferred.resolve({project_id: '12'});
spyOn(glance, 'getNamespaces').and.returnValue(deferred.promise);
spyOn(session, 'get').and.returnValue(sessionDeferred.promise);
ctrl = $controller('ImageOverviewController',
{
'$scope': {context: {loadPromise: deferred.promise}}

View File

@ -8,45 +8,35 @@
cls="dl-horizontal"
item="ctrl.image"
property-groups="[[
'type', 'status', 'size', 'min_disk', 'min_ram', 'disk_format',
'container_format']]">
'id', 'type', 'status', 'size', 'min_disk', 'min_ram', 'disk_format',
'container_format', 'created_at', 'updated_at']]">
</hz-resource-property-list>
</div>
<div class="col-md-6 detail">
<h3>{$ 'Security' | translate $}</h3>
<hr>
<dl class="dl-horizontal">
<dt translate>Owner</dt>
<dd>{$ ctrl.image.owner $}</dd>
<dt translate>Filename</dt>
<dd>{$ ctrl.image.properties.filename $}</dd>
<dd>{$ ctrl.image.properties.filename | noValue $}</dd>
<dt translate>Visibility</dt>
<dd>{$ ctrl.image | imageVisibility $}</dd>
<dd>{$ ctrl.image | imageVisibility:ctrl.projectId $}</dd>
<dt translate>Protected</dt>
<dd>{$ ctrl.image.protected | yesno $}</dd>
<dt translate>Checksum</dt>
<dd>{$ ctrl.image.checksum $}</dd>
<dd>{$ ctrl.image.checksum | noValue $}</dd>
</dl>
</div>
</div>
<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">
<h3 translate>Custom Properties</h3>
<hr>
<dl class="dl-horizontal">
<div ng-repeat="prop in ctrl.image.properties">
<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>
</dl>
</div>

View File

@ -81,7 +81,8 @@
filters: ['uppercase']
})
.setProperty('created_at', {
label: gettext('Created At')
label: gettext('Created At'),
filters: ['simpleDate']
})
.setProperty('disk_format', {
label: gettext('Disk Format'),
@ -90,10 +91,6 @@
.setProperty('id', {
label: gettext('ID')
})
.setProperty('is_public', {
label: gettext('Is Public'),
filters: ['yesno']
})
.setProperty('type', {
label: gettext('Type'),
filters: [imagesService.imageType]
@ -129,7 +126,8 @@
label: gettext('Tags')
})
.setProperty('updated_at', {
label: gettext('Updated At')
label: gettext('Updated At'),
filters: ['simpleDate']
})
.setProperty('virtual_size', {
label: gettext('Virtual Size')
@ -172,6 +170,10 @@
priority: 1,
itemInTransitionFunction: imagesService.isInTransition
})
.append({
id: 'visibility',
priority: 1
})
.append({
id: 'protected',
priority: 1
@ -208,6 +210,18 @@
{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({
label: gettext('Protected'),
name: 'protected',
@ -330,7 +344,7 @@
});
$routeProvider.when('/admin/images/', {
templateUrl: path + 'panel.html'
templateUrl: path + 'admin-panel.html'
});
function goToAngularDetails(params) {

View File

@ -20,7 +20,9 @@
.factory('horizon.app.core.images.service', imageService);
imageService.$inject = [
'$filter',
'horizon.app.core.openstack-service-api.glance',
'horizon.app.core.openstack-service-api.userSession',
'horizon.app.core.images.transitional-statuses'
];
@ -34,9 +36,10 @@
* but do not need to be restricted to such use. Each exposed function
* is documented below.
*/
function imageService(glance, transitionalStatuses) {
function imageService($filter, glance, userSession, transitionalStatuses) {
return {
getDetailsPath: getDetailsPath,
getImagePromise: getImagePromise,
getImagesPromise: getImagesPromise,
imageType: imageType,
isInTransition: isInTransition
@ -99,17 +102,34 @@
* 'trackBy' to assist the display mechanism when updating rows.
*/
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) {
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.visibility = $filter('imageVisibility')(image, projectId);
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() {
it("provides a promise that gets translated", inject(function($q, $injector, $timeout) {
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 deferredSession = $q.defer();
spyOn(glance, 'getImages').and.returnValue(deferred.promise);
spyOn(session, 'get').and.returnValue(deferredSession.promise);
var result = service.getImagesPromise({});
deferred.resolve({data: {items: [{id: 1, updated_at: 'jul1'}]}});
deferredSession.resolve({project_id: '12'});
$timeout.flush();
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) {
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) {

View File

@ -17,7 +17,7 @@
describe('horizon.app.core.images.controller.CreateVolumeController', function () {
var controller, quotaChartDefaults, $scope, $filter, getAbsoluteLimitsSpy;
var controller, quotaChartDefaults, $scope, $filter, getAbsoluteLimitsSpy, nova;
var cinder = {
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.framework.widgets.charts'));
beforeEach(module('horizon.framework.util.filters'));
beforeEach(inject(function ($injector, _$rootScope_, _$filter_) {
nova = {
getAvailabilityZones: function() {
return {
success: function(callback) {
return callback({ items: [{zoneName: 'zone1'}] });
}
};
}
};
$scope = _$rootScope_.$new();
$scope.image = {
name: 'ImageName',
@ -364,6 +364,37 @@
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 expectedVolume = {
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 class="hz-icon-required fa fa-asterisk"></span>
</label>
<select class="form-control" id="availability-zone" ng-model="createVolumeCtrl.volume.availability_zone" ng-required="true">
<option value="" selected="selected" translate>Select a zone</option>
<option ng-repeat="availabilityZone in createVolumeCtrl.availabilityZones"
ng-value="availabilityZone.zoneName">
{$ availabilityZone.zoneName $}
</option>
<select ng-options="item for item in createVolumeCtrl.availabilityZones"
class="form-control"
id="availability-zone"
ng-model="createVolumeCtrl.volume.availability_zone"
ng-required="true">
</select>
</div>
</div>

View File

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