Generic details display framework

This patch provides the ability for the registered detail views for
any resource type to be generically presented.

This patch does the following:
 * Adds a directive that displays a set of views (i.e. details sub-views)
 * Adds a Generic Detail display for routed pages
 * Adds the concept of a Descriptor which contains a resource type name
   and an identifier.  The identifier can be something as simple as
   a string, but may also be an object (if the resource type needs more
   than one value to look up its data, e.g. Pool Members)
 * Adds the ability for a resource type to have knowledge about how one
   of its items may be loaded, so any detail page can fetch the information
   given a basic context
 * Adds a generic Angular page (since they all just route to ng-views).
   We will see this used in subsequent patches as well.
 * Sets up a Django route to a non-navigational panel for the Details

Change-Id: Ie116b52ba196f9240fdc6bbc4a12d37beb9b9fcf
Partially-Implements: blueprint angular-registry
This commit is contained in:
Matt Borland 2016-03-23 10:22:44 -06:00
parent ea8e7a504a
commit 11968c840c
18 changed files with 688 additions and 7 deletions

View File

@ -56,7 +56,7 @@
*/
function registryService(extensibleService) {
function ResourceType() {
function ResourceType(type) {
// 'properties' contains information about properties associated with
// this resource type. The expectation is that the key is the 'code'
// name of the property and the value conforms to the standard
@ -66,6 +66,31 @@
this.getName = getName;
this.label = label;
this.format = format;
this.type = type;
this.setLoadFunction = setLoadFunction;
this.load = load;
// These members support the ability of a type to provide a function
// that, given an object in the structure presented by the
// load() function, produces a human-readable name.
this.itemNameFunction = defaultItemNameFunction;
this.setItemNameFunction = setItemNameFunction;
this.itemName = itemName;
// The purpose of these members is to allow details to be retrieved
// automatically from such a path, or similarly to create a path
// to such a route from any reference. This establishes a two-way
// relationship between the path and the identifier(s) for the item.
// The path could be used as part of a details route, for example:
//
// An identifier of 'abc-defg' would yield '/abc-defg' which
// could be used in a details url, such as:
// '/details/OS::Glance::Image/abc-defg'
this.pathParser = defaultPathParser;
this.setPathParser = setPathParser;
this.parsePath = parsePath;
this.setPathGenerator = setPathGenerator;
this.pathGenerator = defaultPathGenerator;
// itemActions is a list of actions that can be executed upon a single
// item. The list is made extensible so it can be added to independently.
@ -77,6 +102,14 @@
this.batchActions = [];
extensibleService(this.batchActions, this.batchActions);
// detailsViews is a list of views that can be shown on a details view.
// For example, each item added to this list could be represented
// as a tab of a details view.
this.detailsViews = [];
extensibleService(this.detailsViews, this.detailsViews);
// Function declarations
/**
* @ngdoc function
* @name setProperty
@ -135,6 +168,141 @@
return this;
}
/**
* @ngdoc function
* @name setPathParser
* @description
* Sets a function that is used to parse paths. See parsePath.
* @example
```
getResourceType('thing').setPathParser(func);
function func(path) {
return path.replace('-', '');
}
var descriptor = resourceType.parsePath(path);
```
*/
function setPathParser(func) {
this.pathParser = func;
return this;
}
/**
* @ngdoc function
* @name parsePath
* @description
* Given a subpath, produce an object that describes the object
* enough to load it from an API. This is used in details
* routes, which must generate an object that has enough
* fidelity to fetch the object. In many cases this is a simple
* ID, but in others there may be multiple IDs that are required
* to fetch the data.
* @example
```
getResourceType('thing').setPathParser(func);
function func(path) {
return path.replace('-', '');
}
var descriptor = resourceType.parsePath(path);
```
*/
function parsePath(path) {
return {identifier: this.pathParser(path), resourceTypeCode: this.type};
}
/**
* @ngdoc function
* @name setLoadFunction
* @description
* Sets a function that is used to load a single item. See load().
* @example
```
getResourceType('thing').setLoadFunction(func);
function func(descriptor) {
return someApi.get(descriptor.id);
}
var loadPromise = resourceType.load({id: 'some-id'});
```
*/
function setLoadFunction(func) {
this.loadFunction = func;
return this;
}
/**
* @ngdoc function
* @name load
* @description
* Loads a single item
* @example
```
getResourceType('thing').setLoadFunction(func);
function func(descriptor) {
return someApi.get(descriptor.id);
}
var loadPromise = resourceType.load({id: 'some-id'});
```
*/
function load(descriptor) {
return this.loadFunction(descriptor);
}
/**
* @ngdoc function
* @name setPathGenerator
* @description
* Sets a function that is used generate paths. Accepts the
* resource-type-specific id/object.
* The subpath returned should NOT have a leading slash.
* @example
```
getResourceType('thing').setPathGenerator(func);
function func(descriptor) {
return 'load-balancer/' + descriptor.balancerId
+ '/listener/' + descriptor.id
}
```
*/
function setPathGenerator(func) {
this.pathGenerator = func;
return this;
}
// Functions relating item names, described above.
function defaultItemNameFunction(item) {
return item.name;
}
function setItemNameFunction(func) {
this.itemNameFunction = func;
return this;
}
function itemName(item) {
return this.itemNameFunction(item);
}
// Functions providing default path parsers and generators
// so most common objects don't have to re-specify the most common
// case, which is that a path component for an identifier is just the ID.
function defaultPathParser(path) {
return path;
}
function defaultPathGenerator(id) {
return id;
}
/**
* @ngdoc function
* @name getName
@ -229,11 +397,33 @@
}
var resourceTypes = {};
var defaultDetailsTemplateUrl = false;
var registry = {
setDefaultDetailsTemplateUrl: setDefaultDetailsTemplateUrl,
getDefaultDetailsTemplateUrl: getDefaultDetailsTemplateUrl,
getResourceType: getResourceType,
initActions: initActions
};
function getDefaultDetailsTemplateUrl() {
return defaultDetailsTemplateUrl;
}
/*
* @ngdoc function
* @name setDefaultDetailsTemplateUrl
* @param {String} url - The URL for the template to be used
* @description
* The idea is that in the case that someone links to a details page for a
* resource and there is no view registered, there can be a default view.
* For example, if there's a generic property viewer, that could display
* the resource.
*/
function setDefaultDetailsTemplateUrl(url) {
defaultDetailsTemplateUrl = url;
return this;
}
/*
* @ngdoc function
* @name getResourceType
@ -245,7 +435,7 @@
*/
function getResourceType(type, config) {
if (!resourceTypes.hasOwnProperty(type)) {
resourceTypes[type] = new ResourceType();
resourceTypes[type] = new ResourceType(type);
}
if (angular.isDefined(config)) {
angular.extend(resourceTypes[type], config);

View File

@ -34,6 +34,10 @@
expect(service).toBeDefined();
});
it('establishes detailsViews on a resourceType object', function() {
expect(service.getResourceType('something').detailsViews).toBeDefined();
});
it('init calls initScope on item and batch actions', function() {
var action = { service: { initScope: angular.noop } };
spyOn(action.service, 'initScope');
@ -79,6 +83,11 @@
});
});
it('get/setDefaultDetailsTemplateUrl sets/retrieves a URL', function() {
service.setDefaultDetailsTemplateUrl('/my/path.html');
expect(service.getDefaultDetailsTemplateUrl()).toBe('/my/path.html');
});
describe('label', function() {
var label;
beforeEach(function() {
@ -175,6 +184,78 @@
});
});
});
describe('functions the resourceType object', function() {
var type;
beforeEach(function() {
type = service.getResourceType('something');
});
it('itemName defaults to returning the name of an item', function() {
var item = {name: 'MegaMan'};
expect(type.itemName(item)).toBe('MegaMan');
});
it('setItemNameFunction supplies a function for interpreting names', function() {
var item = {name: 'MegaMan'};
var func = function(x) { return 'Mr. ' + x.name; };
type.setItemNameFunction(func);
expect(type.itemName(item)).toBe('Mr. MegaMan');
});
it("pathParser return has resourceTypeCode embedded", function() {
expect(type.parsePath('abcd').resourceTypeCode).toBe('something');
});
it("pathParser defaults to using the full path as the id", function() {
expect(type.parsePath('abcd').identifier).toBe('abcd');
});
it("setPathParser sets the function for parsing the path", function() {
var func = function(x) {
var y = x.split('/');
return {poolId: y[0], memberId: y[1]};
};
var expected = {
identifier: {poolId: '12', memberId: '42'},
resourceTypeCode: 'something'
};
type.setPathParser(func);
expect(type.parsePath('12/42')).toEqual(expected);
});
it("pathParser defaults to using the full path as the id", function() {
expect(type.parsePath('abcd').identifier).toBe('abcd');
});
it("setPathParser sets the function for parsing the path", function() {
var func = function(x) {
var y = x.split('/');
return {poolId: y[0], memberId: y[1]};
};
var expected = {
identifier: {poolId: '12', memberId: '42'},
resourceTypeCode: 'something'
};
type.setPathParser(func);
expect(type.parsePath('12/42')).toEqual(expected);
});
it('setPathGenerator sets the path identifier generator', function() {
var func = function(x) {
return x.poolId + '/' + x.memberId;
};
type.setPathGenerator(func);
var identifier = {poolId: '12', memberId: '42'};
expect(type.pathGenerator(identifier)).toBe('12/42');
});
it('setLoadFunction sets the function used by "load"', function() {
var api = {
loadMe: function() { return {an: 'object'}; }
};
type.setLoadFunction(api.loadMe);
expect(type.load()).toEqual({an: 'object'});
});
});
});
})();

View File

@ -0,0 +1,68 @@
/*
* (c) Copyright 2016 Hewlett Packard Enterprise Development Company LP
*
* 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.framework.widgets.details')
.directive('hzDetails', hzDetails);
hzDetails.$inject = ['$window'];
/**
* @ngdoc directive
* @name horizon.framework.widgets.details:hzDetails
* @description
* Given a list of details views, provides a tab for each if more than one;
* show a single view without tabs if only one; and if none then display
* the default details view.
*
* The 'context' is an object that is provided by the resource type
* features, consisting of an 'identifier' member and a 'loadPromise'
* that are used in conveying basic information about the subject of the
* details views.
* @example
*
* ```
* js:
* ctrl.context = {
* identifier: 'some-id',
* loadPromise: imageResourceType.load('some-id')
* };
* ctrl.defaultTemplateUrl = '/full/path/to/some/fallthough/template.html'
*
* markup:
* <hz-details
* views="ctrl.resourceType.detailsViews"
* context="ctrl.context"
* default-template-url="ctrl.defaultTemplateUrl"
* ></hz-details>
* ```
*
*/
function hzDetails($window) {
var directive = {
restrict: 'E',
scope: {
views: '=',
context: '=',
defaultTemplateUrl: '='
},
templateUrl: $window.STATIC_URL + 'framework/widgets/details/details.html'
};
return directive;
}
})();

View File

@ -0,0 +1,13 @@
<div ng-if="views.length > 1">
<tabset class="tabset-details">
<tab class="tab-details" ng-repeat="view in views" heading="{$ view.name $}">
<ng-include src="view.template"></ng-include>
</tab>
</tabset>
</div>
<div ng-if="views.length === 1">
<ng-include src="views[0].template"></ng-include>
</div>
<div ng-if="views.length === 0">
<ng-include src="defaultTemplateUrl"></ng-include>
</div>

View File

@ -0,0 +1,29 @@
/**
* (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';
/**
* @ngdoc overview
* @ngname horizon.framework.widgets.details
*
* @description
* Provides all of the common features for details.
*/
angular.module('horizon.framework.widgets.details', []);
})();

View File

@ -0,0 +1,47 @@
/*
* (c) Copyright 2016 Hewlett Packard Enterprise Development Company LP
*
* 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.framework.widgets.details')
.controller('RoutedDetailsViewController', controller);
controller.$inject = [
'horizon.framework.conf.resource-type-registry.service',
'$routeParams',
'$rootScope'
];
function controller(
registry,
$routeParams,
$rootScope
) {
var ctrl = this;
ctrl.resourceType = registry.getResourceType($routeParams.type);
ctrl.context = ctrl.resourceType.parsePath($routeParams.path);
ctrl.context.loadPromise = ctrl.resourceType.load(ctrl.context.identifier);
ctrl.context.loadPromise.then(function loadData(response) {
registry.initActions($routeParams.type, $rootScope.$new());
ctrl.itemData = response.data;
ctrl.itemName = ctrl.resourceType.itemName(response.data);
});
ctrl.defaultTemplateUrl = registry.getDefaultDetailsTemplateUrl();
}
})();

View File

@ -0,0 +1,70 @@
/**
* (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('RoutedDetailsViewController', function() {
var ctrl, deferred, $timeout;
beforeEach(module('horizon.framework.widgets.details'));
beforeEach(inject(function($injector, $controller, $q, _$timeout_) {
deferred = $q.defer();
$timeout = _$timeout_;
var service = {
getResourceType: function() { return {
load: function() { return deferred.promise; },
parsePath: function() { return {a: 'my-context'}; },
itemName: function() { return 'A name'; }
}; },
getDefaultDetailsTemplateUrl: angular.noop,
initActions: angular.noop
};
ctrl = $controller("RoutedDetailsViewController", {
'horizon.framework.conf.resource-type-registry.service': service,
'$routeParams': {
type: 'OS::Glance::Image',
path: '1234'
}
});
}));
it('sets resourceType', function() {
expect(ctrl.resourceType).toBeDefined();
});
it('sets context', function() {
expect(ctrl.context.a).toEqual('my-context');
});
it('sets itemData when item loads', function() {
deferred.resolve({data: {some: 'data'}});
expect(ctrl.itemData).toBeUndefined();
$timeout.flush();
expect(ctrl.itemData).toEqual({some: 'data'});
});
it('sets itemName when item loads', function() {
deferred.resolve({data: {some: 'data'}});
expect(ctrl.itemData).toBeUndefined();
$timeout.flush();
expect(ctrl.itemName).toEqual('A name');
});
});
})();

View File

@ -0,0 +1,23 @@
<div ng-controller="RoutedDetailsViewController as ctrl">
<div class="page-header">
<ol class="breadcrumb">
<li><a href="javascript:history.back()">Back</a></li>
</ol>
<div class="row">
<div class="col-xs-12 col-sm-9 text-left">
<span class="h1">{$ ctrl.itemName $}</span>
</div>
<div class="col-xs-12 col-sm-3 text-right details-item-actions" ng-if="ctrl.itemData">
<actions allowed="ctrl.resourceType.itemActions" type="row" item="ctrl.itemData"></actions>
</div>
</div>
</div>
<hz-details
views="ctrl.resourceType.detailsViews"
context="ctrl.context"
default-template-url="ctrl.defaultTemplateUrl"
></hz-details>
</div>

View File

@ -1,9 +1,26 @@
/*
* (c) Copyright 2016 Hewlett Packard Enterprise Development Company LP
*
* 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.framework.widgets', [
'horizon.framework.widgets.headers',
'horizon.framework.widgets.details',
'horizon.framework.widgets.help-panel',
'horizon.framework.widgets.wizard',
'horizon.framework.widgets.table',

View File

@ -0,0 +1,25 @@
# (c) Copyright 2015 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.
from django.utils.translation import ugettext_lazy as _
import horizon
class NGDetails(horizon.Panel):
name = _("Details")
slug = 'ngdetails'
def nav(self, context):
return False

View File

@ -0,0 +1,24 @@
# (c) Copyright 2015 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.
from django.conf.urls import patterns
from django.conf.urls import url
from openstack_dashboard.dashboards.project.ngdetails import views
urlpatterns = patterns(
'openstack_dashboard.dashboards.project.ngdetails.views',
url('', views.IndexView.as_view(), name='index'),
)

View File

@ -0,0 +1,19 @@
# (c) Copyright 2015 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.
from django.views import generic
class IndexView(generic.TemplateView):
template_name = 'angular.html'

View File

@ -0,0 +1,30 @@
# (c) Copyright 2015 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.
# The slug of the dashboard the PANEL associated with. Required.
PANEL_DASHBOARD = 'project'
# The slug of the panel group the PANEL is associated with.
# If you want the panel to show up without a panel group,
# use the panel group "default".
PANEL_GROUP = 'compute'
# The slug of the panel to be added to HORIZON_CONFIG. Required.
PANEL = 'ngdetails'
# If set to True, this settings file will not be added to the settings.
DISABLED = False
# Python panel class of the PANEL to be added.
ADD_PANEL = 'openstack_dashboard.dashboards.project.ngdetails.panel.NGDetails'

View File

@ -49,11 +49,16 @@
performRegistrations
]);
config.$inject = ['$provide', '$windowProvider'];
config.$inject = ['$provide', '$windowProvider', '$routeProvider'];
function config($provide, $windowProvider) {
function config($provide, $windowProvider, $routeProvider) {
var path = $windowProvider.$get().STATIC_URL + 'app/core/';
$provide.constant('horizon.app.core.basePath', path);
$routeProvider
.when('/project/ngdetails/:type/:path', {
templateUrl: $windowProvider.$get().STATIC_URL +
'framework/widgets/details/routed-details-view.html'
});
}
function performRegistrations(registry) {

View File

@ -77,7 +77,6 @@
});
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'}
@ -102,5 +101,4 @@
expect(Object.keys(imageFormats).length).toEqual(11);
});
});
})();

View File

@ -0,0 +1,16 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Horizon" %}{% endblock %}
{% block breadcrumb_nav %}{% endblock %}
{% block page_header %}
{% endblock %}
{% block ng_route_base %}
<base href="{{ WEBROOT }}">
{% endblock %}
{% block main %}
<div ng-view></div>
{% endblock %}

View File

@ -0,0 +1,26 @@
---
prelude: >
A Details page for a resource type (e.g. Images)
may now use the Angular application-level registry
to register views so developers may easily create
or extend details views. In this implementation
these views are presented as tabs within the
Details page.
features:
- A directive (hz-details) provides the ability to
intelligently display a set of views (typically for
a Details context).
- A generic Details display parses the location to
determine the resource type, and displays relevant
details views for that type.
- A Descriptor concept allows convenient passing of
information that can globally identify an object,
for use in generic views and actions.
- Horizon now has a (non-navigational) route in Django
so generic details pages are deep-linked.
- A shared Django template is now available for use by
any Angular page.
upgrade:
- (optional) Use the common Angular template as the
basis of any Angular pages to minimize boilerplate code
and to ensure that we use similar features/framing.