Image Detail Redesign (Angular/UX)

Image Detail UX redesign, implemented in AngularJS.  This uses the design
at http://invis.io/962Q35HVQ as its base.  For now, basic properties are
displayed.

The actions on the detail screen will be implemented in a later patch.

To test, modify openstack_dashboard/enabled/_1051_project_ng_images_panel.py
and set DISABLED = False

There's a bug around translation of images which is introduced
by this change https://bugs.launchpad.net/horizon/+bug/1487590

Partially-Implements: blueprint angularize-images-table

Co-Authored-By: Rajat Vig <rajatv@thoughtworks.com>
Co-Authored-By: Dan Siwiec <dan.siwiec@thoughtworks.com>
Co-Authored-By: Kyle Olivo <kyle@kyleolivo.com>
Co-Authored-By: Coleman Beasley <coleman.beasley@thoughtworks.com>
Co-Authored-By: Matt Borland <matt.borland@hpe.com>
Co-Authored-By: Tyr Johanson <tyr@hpe.com>

Change-Id: I9882970b40b52a402e5693f6993f1b50c0a819f6
This commit is contained in:
Rajat Vig 2015-10-06 14:46:41 -07:00 committed by Matt Borland
parent 80bbc35944
commit 1a17b8608f
10 changed files with 358 additions and 18 deletions

View File

@ -1,11 +1,12 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Images" %}{% endblock %}
{% block page_header %}{% endblock %}
{% block ng_route_base %}
<base href="{{ WEBROOT }}">
{% endblock %}
{% block main %}
<ng-include src="'{{ STATIC_URL }}app/core/images/table/images-table.html'"></ng-include>
<div ng-view></div>
{% endblock %}

View File

@ -20,5 +20,5 @@ from openstack_dashboard.dashboards.project.ngimages import views
urlpatterns = patterns(
'openstack_dashboard.dashboards.project.ngimages.views',
url(r'^$', views.IndexView.as_view(), name='index'),
url('', views.IndexView.as_view(), name='index'),
)

View File

@ -0,0 +1,64 @@
/*
*
* 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('ImageDetailController', ImageDetailController);
ImageDetailController.$inject = [
'horizon.app.core.images.tableRoute',
'horizon.app.core.openstack-service-api.glance',
'horizon.app.core.openstack-service-api.keystone',
'$routeParams'
];
function ImageDetailController(
tableRoute,
glanceAPI,
keystoneAPI,
$routeParams)
{
var ctrl = this;
ctrl.image = {};
ctrl.project = {};
ctrl.hasCustomProperties = false;
ctrl.tableRoute = tableRoute;
var imageId = $routeParams.imageId;
init();
function init() {
// Load the elements that are used in the overview.
glanceAPI.getImage(imageId).success(onGetImage);
ctrl.hasCustomProperties =
angular.isDefined(ctrl.image) &&
angular.isDefined(ctrl.image.properties);
}
function onGetImage(image) {
ctrl.image = image;
ctrl.image.properties = Object.keys(ctrl.image.properties).map(function mapProps(prop) {
return {name: prop, value: ctrl.image.properties[prop]};
});
}
}
})();

View File

@ -0,0 +1,88 @@
/*
*
* 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() {
beforeEach(module('ui.bootstrap'));
beforeEach(module('horizon.app.core'));
describe("ImageDetailController", function() {
var ctrl, glanceAPI, keystoneAPI, imageMock, projectMock, tableRoute;
beforeEach(inject(function($injector, $controller) {
imageMock = {
owner: 'mock_image_owner',
properties: {
kernel_id: 'mock_kernel_id'
}
};
projectMock = {
name: 'mock_project'
};
keystoneAPI = {
getProject: function() {
return {
success: function(callback) {
callback(projectMock);
}
};
}
};
glanceAPI = {
getImage: function() {
return {
success: function(callback) {
callback(imageMock);
}
};
}
};
spyOn(glanceAPI, 'getImage').and.callThrough();
spyOn(keystoneAPI, 'getProject').and.callThrough();
tableRoute = $injector.get('horizon.app.core.images.tableRoute');
ctrl = $controller("ImageDetailController", {
'horizon.app.core.openstack-service-api.glance': glanceAPI,
'horizon.app.core.openstack-service-api.keystone': keystoneAPI,
'$routeParams': {
imageId: '1234'
}
});
}));
it('defines the controller', function() {
expect(ctrl).toBeDefined();
});
it('should set table route', function() {
expect(ctrl.tableRoute).toEqual(tableRoute);
});
it('should create a map of the image properties', function() {
expect(ctrl.hasCustomProperties).toEqual(true);
expect(ctrl.image.properties).toEqual([{name: 'kernel_id', value: 'mock_kernel_id'}]);
});
});
});
})();

View File

@ -0,0 +1,106 @@
<div ng-controller="ImageDetailController as ctrl">
<div class="page-header">
<ol class="breadcrumb">
<li><a href="{$ ctrl.tableRoute $}"><translate>Images</translate></a></li>
<li class="active">{$ ::ctrl.image.name $}</li>
</ol>
<p>{$ ctrl.image.properties.description $}</p>
<ul class="list-inline">
<li>
<strong translate>Status</strong>
{$ ::ctrl.image.status $}
</li>
<li ng-if="ctrl.image.properties.filename">
<strong translate>Filename</strong>
{$ ::ctrl.image.properties.filename $}
</li>
<li>
<strong translate>Type</strong>
{$ ctrl.image | imageType $}
</li>
</ul>
</div>
<tabset>
<tab heading="{$ 'Overview' | translate $}">
<div class="row">
<div class="col-md-6 detail">
<h3 translate>Image</h3>
<hr>
<dl class="dl-horizontal">
<div>
<dt translate>Size</dt>
<dd>{$ ctrl.image.size | bytes $}</dd>
</div>
<div>
<dt translate>Min. Disk</dt>
<dd>{$ ctrl.image.min_disk | gb $}</dd>
</div>
<div>
<dt translate>Min. RAM</dt>
<dd>{$ ctrl.image.min_ram | mb $}</dd>
</div>
<div>
<dt translate>Disk Format</dt>
<dd>{$ ctrl.image.disk_format | uppercase $}</dd>
</div>
<div>
<dt translate>Container Format</dt>
<dd>{$ ctrl.image.container_format | uppercase $}</dd>
</div>
</dl>
</div>
<div class="col-md-6 detail">
<h3>{$ 'Security' | translate $}</h3>
<hr>
<dl class="dl-horizontal">
<div>
<dt translate>Visibility</dt>
<dd>{$ ctrl.image | imageVisibility $}</dd>
</div>
<div>
<dt translate>Protected</dt>
<dd>{$ ::ctrl.image.protected | yesno $}</dd>
</div>
<div>
<dt translate>Checksum</dt>
<dd>{$ ::ctrl.image.checksum $}</dd>
</div>
</dl>
</div>
</div>
<div class="row">
<div class="col-md-6 detail">
<h3 translate>Record Properties</h3>
<hr>
<dl class="dl-horizontal">
<div>
<dt translate>Created</dt>
<dd>{$ ctrl.image.created_at | date:'short' $}</dd>
</div>
<div>
<dt translate>Updated</dt>
<dd>{$ ctrl.image.updated_at | date:'short' $}</dd>
</div>
<div>
<dt translate>ID</dt>
<dd>{$ ctrl.image.id $}</dd>
</div>
</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">
<div>
<dt data-toggle="tooltip" title="{$ prop.name $}">{$ prop.name $}</dt>
<dd>{$ prop.value $}</dd>
</div>
</div>
</dl>
</div>
</div>
</tab>
</tabset>
</div>

View File

@ -26,13 +26,14 @@
* to support and display images related content.
*/
angular
.module('horizon.app.core.images', [])
.module('horizon.app.core.images', ['ngRoute'])
.constant('horizon.app.core.images.events', events())
.config(config);
config.$inject = [
'$provide',
'$windowProvider'
'$windowProvider',
'$routeProvider'
];
/**
@ -48,12 +49,31 @@
}
/**
* @name horizon.app.core.images.basePath
* @description Base path for the images code
* @name horizon.app.core.images.tableRoute
* @name horizon.app.core.images.detailsRoute
* @description Routes used by this module.
*/
function config($provide, $windowProvider) {
function config($provide, $windowProvider, $routeProvider) {
var path = $windowProvider.$get().STATIC_URL + 'app/core/images/';
$provide.constant('horizon.app.core.images.basePath', path);
var webroot = $windowProvider.$get().WEBROOT;
var tableUrl = path + "table/";
var projectTableRoute = webroot + 'project/ngimages/';
var detailsUrl = path + "detail/";
var projectDetailsRoute = webroot + 'project/ngimages/details/';
// Share the routes as constants so that views within the images module
// can create links to each other.
$provide.constant('horizon.app.core.images.tableRoute', projectTableRoute);
$provide.constant('horizon.app.core.images.detailsRoute', projectDetailsRoute);
$routeProvider
.when(projectTableRoute, {
templateUrl: tableUrl + 'images-table.html'
})
.when(projectDetailsRoute + ':imageId', {
templateUrl: detailsUrl + 'image-detail.html'
});
}
})();

View File

@ -22,23 +22,76 @@
});
});
describe('horizon.app.core.images.basePath constant', function () {
var imagesBasePath, staticUrl;
describe('horizon.app.core.images.tableRoute constant', function () {
var tableRoute, webRoot;
beforeEach(module('horizon.app.core'));
beforeEach(module('horizon.app.core.images'));
beforeEach(inject(function ($injector) {
imagesBasePath = $injector.get('horizon.app.core.images.basePath');
staticUrl = $injector.get('$window').STATIC_URL;
tableRoute = $injector.get('horizon.app.core.images.tableRoute');
webRoot = $injector.get('$window').WEBROOT;
}));
it('should be defined', function () {
expect(imagesBasePath).toBeDefined();
expect(tableRoute).toBeDefined();
});
it('should equal to "/static/app/core/images/"', function () {
expect(imagesBasePath).toEqual(staticUrl + 'app/core/images/');
it('should equal to "/project/ngimages/"', function () {
expect(tableRoute).toEqual(webRoot + 'project/ngimages/');
});
});
describe('horizon.app.core.images.detailsRoute constant', function () {
var detailsRoute, webRoot;
beforeEach(module('horizon.app.core'));
beforeEach(module('horizon.app.core.images'));
beforeEach(inject(function ($injector) {
detailsRoute = $injector.get('horizon.app.core.images.detailsRoute');
webRoot = $injector.get('$window').WEBROOT;
}));
it('should be defined', function () {
expect(detailsRoute).toBeDefined();
});
it('should equal to "/project/ngimages/details/"', function () {
expect(detailsRoute).toEqual(webRoot + 'project/ngimages/details/');
});
});
describe('$routeProvider should be configured for images', function() {
var staticUrl, $routeProvider;
beforeEach(function() {
module('ngRoute');
angular.module('routeProviderConfig', [])
.config(function(_$routeProvider_) {
$routeProvider = _$routeProvider_;
spyOn($routeProvider, 'when').and.callThrough();
});
module('routeProviderConfig');
module('horizon.app.core');
inject(function ($injector) {
staticUrl = $injector.get('$window').STATIC_URL;
});
});
it('should set table and detail path', function() {
expect($routeProvider.when.calls.count()).toEqual(2);
var imagesRouteCallArgs = $routeProvider.when.calls.argsFor(0);
expect(imagesRouteCallArgs).toEqual([
'/project/ngimages/', {templateUrl: staticUrl + 'app/core/images/table/images-table.html'}
]);
var imagesDetailsCallArgs = $routeProvider.when.calls.argsFor(1);
expect(imagesDetailsCallArgs).toEqual([
'/project/ngimages/details/:imageId',
{ templateUrl: staticUrl + 'app/core/images/detail/image-detail.html'}
]);
});
});
})();

View File

@ -7,7 +7,6 @@
default-sort="name"
default-sort-reverse="false"
class="table-striped table-rsp table-detail modern">
<thead>
<tr>
<!--
@ -68,7 +67,7 @@
duration="200">
</span>
</td>
<td class="rsp-p1">{$ image.name $}</td>
<td class="rsp-p1"><a ng-href="{$ table.detailsRoute + image.id $}">{$ image.name $}</a></td>
<td class="rsp-p1">{$ image | imageType $}</td>
<td class="rsp-p1">{$ image.status | imageStatus $}</td>
<td class="rsp-p2">{$ image.filtered_visibility $}</td>
@ -111,7 +110,7 @@
</dl>
<dl class="col-sm-2">
<dt translate>Format</dt>
<dd>{$ image.disk_format | noValue $}</dd>
<dd>{$ image.disk_format | noValue | uppercase $}</dd>
</dl>
<dl class="col-sm-2">
<dt translate>Size</dt>

View File

@ -24,6 +24,7 @@
ImagesTableController.$inject = [
'$q',
'$scope',
'horizon.app.core.images.detailsRoute',
'horizon.app.core.images.table.batch-actions.service',
'horizon.app.core.images.table.row-actions.service',
'horizon.app.core.images.events',
@ -43,6 +44,7 @@
function ImagesTableController(
$q,
$scope,
detailsRoute,
batchActionsService,
rowActionsService,
events,
@ -52,6 +54,8 @@
) {
var ctrl = this;
ctrl.detailsRoute = detailsRoute;
ctrl.checked = {};
ctrl.images = [];

View File

@ -53,7 +53,7 @@
2: {id: '2', is_public: false, owner: 'not_me', filtered_visibility: 'Shared with Me'}
};
var $scope, controller, events;
var $scope, controller, events, detailsRoute;
beforeEach(module('ui.bootstrap'));
beforeEach(module('horizon.framework'));
@ -71,6 +71,7 @@
$scope = _$rootScope_.$new();
events = $injector.get('horizon.app.core.images.events');
controller = $injector.get('$controller');
detailsRoute = $injector.get('horizon.app.core.images.detailsRoute');
spyOn(glanceAPI, 'getImages').and.callThrough();
spyOn(userSession, 'get').and.callThrough();
@ -87,6 +88,10 @@
});
}
it('should set details route properly', function() {
expect(createController().detailsRoute).toEqual(detailsRoute);
});
it('should invoke initialization apis', function() {
var ctrl = createController();
expect(userSession.get).toHaveBeenCalled();