From 3aa3cc934bf77fd656c53f160125ffa69b1e9b81 Mon Sep 17 00:00:00 2001 From: Matt Borland Date: Fri, 22 Apr 2016 10:00:30 -0600 Subject: [PATCH] Adding generic table extensibility This patch adds several features: * Global Actions (registry, extensible) * Drawer Templates (registry, replaceable) * Table Columns (registry, extensible) I moved the resource registrations into their own module so it doesn't clutter the app core module, and to help minimize memory utilization at the app.core level. Change-Id: I4a74a6630cb6ef6515b240855ee68283ef41eff3 Partially-Implements: blueprint angular-registry --- .../conf/resource-type-registry.service.js | 170 +++++++++++++++++- .../resource-type-registry.service.spec.js | 78 ++++++-- openstack_dashboard/static/app/app.module.js | 1 + .../static/app/core/core.module.js | 73 +------- .../static/app/resources/resources.module.js | 84 +++++++++ 5 files changed, 308 insertions(+), 98 deletions(-) create mode 100644 openstack_dashboard/static/app/resources/resources.module.js diff --git a/horizon/static/framework/conf/resource-type-registry.service.js b/horizon/static/framework/conf/resource-type-registry.service.js index b6815eff9b..400b501e07 100644 --- a/horizon/static/framework/conf/resource-type-registry.service.js +++ b/horizon/static/framework/conf/resource-type-registry.service.js @@ -64,11 +64,13 @@ var properties = {}; this.setProperty = setProperty; this.getName = getName; + this.setNames = setNames; this.label = label; this.format = format; this.type = type; this.setLoadFunction = setLoadFunction; this.load = load; + this.loadFunction = function def() { return Promise.resolve({data: {}}); }; // These members support the ability of a type to provide a function // that, given an object in the structure presented by the @@ -77,6 +79,31 @@ this.setItemNameFunction = setItemNameFunction; this.itemName = itemName; + // These members support 'global actions' which are actions associated + // with a resource type, but don't need an existing resource in order + // to be run. + this.globalActions = []; + extensibleService(this.globalActions, this.globalActions); + + // These members support summary templates which are views that are meant + // to exist in a confined area, such as a drawer in a table row. + this.summaryTemplateUrl = false; + this.setSummaryTemplateUrl = setSummaryTemplateUrl; + + // The list function is meant to provide a standard list of a particular + // type, with the data as a result in a promise. For example, Images code + // would register a list function that returns a promise that will resolve + // to all the Images data in list form. + this.listFunction = angular.noop; + this.setListFunction = setListFunction; + + // The table columns are an extensible registration of columns of data + // that could be displayed in a table/grid format. The specification + // for the data elements is defined in the code for hz-dynamic-table. + this.tableColumns = []; + extensibleService(this.tableColumns, this.tableColumns); + this.getTableColumns = getTableColumns; + // 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 @@ -84,7 +111,7 @@ // 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: + // could be used in a details route, such as: // '/details/OS::Glance::Image/abc-defg' this.pathParser = defaultPathParser; this.setPathParser = setPathParser; @@ -168,6 +195,62 @@ return this; } + /** + * @ngdoc function + * @name setListFunction + * @description + * Sets a function that returns a promise, that resolves with a list + * of all the items of a given type. + * @example + ``` + myResourceType.setListFunction(loadData); + + function loadData() { + return myResourceTypeApi.get(); + } + + elsewhere: + myResourceType.list().then(doSomethingWithResult); + ``` + */ + function setListFunction(func) { + this.listFunction = func; + return this; + } + + /** + * @ngdoc function + * @name getTableColumns + * @description + * This is a convenience function that provides the table column + * information back, but places a 'title' member on if not already + * present, using the label provided for the resourceType's member of + * the same name. + * This function is a way of making use of the pre-existing registration + * of a human-readable for the given property, so we don't have to specify + * it again in the column registration. + * @example + ``` + resourceType.setProperty('owner', { + label: gettext('Owner') + }); + resourceType.tableColumns.append({'id': 'owner'}); // no 'title' + + var columns = resourceType.getTableColumns(); + // columns[0] will contain {'id': 'owner', 'title': 'Owner'} + + ``` + */ + function getTableColumns() { + return this.tableColumns.map(mapTableInfo); + + function mapTableInfo(x) { + var tableInfo = x; + tableInfo.title = x.title || label(x.id); + return tableInfo; + } + } + /** * @ngdoc function * @name setPathParser @@ -271,6 +354,7 @@ + '/listener/' + descriptor.id } + var path = resourceType.detailsPath({id: 12, balancerId: 'abasefasdf'); ``` */ function setPathGenerator(func) { @@ -278,6 +362,13 @@ return this; } + // The reason for this function as opposed to just setting the value + // is solely to make it easy to chain commands. + function setSummaryTemplateUrl(url) { + this.summaryTemplateUrl = url; + return this; + } + // Functions relating item names, described above. function defaultItemNameFunction(item) { return item.name; @@ -323,6 +414,24 @@ } } + /** + * @ngdoc function + * @name setNames + * @description + * Takes in the singular/plural names used for display. + * @example + ``` + var resourceType = getResourceType('thing') + .setNames(gettext('Thing'), gettext('Things')); + }); + + ``` + */ + function setNames(singular, plural) { + this.names = [singular, plural]; + return this; + } + /** * @ngdoc function * @name label @@ -397,14 +506,42 @@ } var resourceTypes = {}; + // The slugs are only used to align Django routes with heat + // type names. In a context without Django routing this is + // not needed. + var slugs = {}; + var defaultSummaryTemplateUrl = false; var defaultDetailsTemplateUrl = false; var registry = { + getResourceType: getResourceType, + initActions: initActions, + getGlobalActions: getGlobalActions, + setDefaultSummaryTemplateUrl: setDefaultSummaryTemplateUrl, + getDefaultSummaryTemplateUrl: getDefaultSummaryTemplateUrl, setDefaultDetailsTemplateUrl: setDefaultDetailsTemplateUrl, getDefaultDetailsTemplateUrl: getDefaultDetailsTemplateUrl, - getResourceType: getResourceType, - initActions: initActions + setSlug: setSlug, + getTypeNameBySlug: getTypeNameBySlug }; + function getTypeNameBySlug(slug) { + return slugs[slug]; + } + + function setSlug(slug, typeName) { + slugs[slug] = typeName; + return this; + } + + function getDefaultSummaryTemplateUrl() { + return defaultSummaryTemplateUrl; + } + + function setDefaultSummaryTemplateUrl(url) { + defaultSummaryTemplateUrl = url; + return this; + } + function getDefaultDetailsTemplateUrl() { return defaultDetailsTemplateUrl; } @@ -424,22 +561,36 @@ return this; } + /* + * @ngdoc function + * @name getGlobalActions + * @description + * This is a convenience function for retrieving all the global actions + * across all the resource types. This is valuable when a page wants to + * display all actions that can be taken without having selected a resource + * type, or otherwise needing to access all global actions. + */ + function getGlobalActions() { + var actions = []; + angular.forEach(resourceTypes, appendActions); + return actions; + + function appendActions(type) { + actions = actions.concat(type.globalActions); + } + } + /* * @ngdoc function * @name getResourceType * @description * Retrieves all information about a resource type. If the resource * type doesn't exist in the registry, this creates a new entry. - * If a configuration is supplied, the resource type is extended to - * use the configuration's properties. */ - function getResourceType(type, config) { + function getResourceType(type) { if (!resourceTypes.hasOwnProperty(type)) { resourceTypes[type] = new ResourceType(type); } - if (angular.isDefined(config)) { - angular.extend(resourceTypes[type], config); - } return resourceTypes[type]; } @@ -454,6 +605,7 @@ function initActions(type, scope) { angular.forEach(resourceTypes[type].itemActions, setActionScope); angular.forEach(resourceTypes[type].batchActions, setActionScope); + angular.forEach(resourceTypes[type].globalActions, setActionScope); function setActionScope(action) { if (action.service.initScope) { diff --git a/horizon/static/framework/conf/resource-type-registry.service.spec.js b/horizon/static/framework/conf/resource-type-registry.service.spec.js index fc85fe33a1..a112d56301 100644 --- a/horizon/static/framework/conf/resource-type-registry.service.spec.js +++ b/horizon/static/framework/conf/resource-type-registry.service.spec.js @@ -61,25 +61,19 @@ }); it('returns a new resource type object', function() { - var value = service.getResourceType('something', {here: "I am"}); + var value = service.getResourceType('something'); expect(value).toBeDefined(); }); - it('takes the given properties', function() { - var value = service.getResourceType('something', {here: "I am"}); - expect(value.here).toEqual('I am'); - }); - it('has an setProperty function', function() { - var value = service.getResourceType('something', {here: "I am"}); + var value = service.getResourceType('something'); expect(value.setProperty).toBeDefined(); }); - it('can be called multiple times, overlaying values', function() { - var value = service.getResourceType('something', {here: "I am"}); - service.getResourceType('something', {another: "Thing"}); - expect(value.here).toBe('I am'); - expect(value.another).toBe('Thing'); + it('can be called multiple times', function() { + var value = service.getResourceType('something'); + var value2 = service.getResourceType('something'); + expect(value).toBe(value2); }); }); @@ -88,6 +82,11 @@ expect(service.getDefaultDetailsTemplateUrl()).toBe('/my/path.html'); }); + it('get/setDefaultSummaryTemplateUrl sets/retrieves a URL', function() { + service.setDefaultSummaryTemplateUrl('/my/path.html'); + expect(service.getDefaultSummaryTemplateUrl()).toBe('/my/path.html'); + }); + describe('label', function() { var label; beforeEach(function() { @@ -161,35 +160,80 @@ }); }); + it("sets and retrieves slugs", function() { + service.setSlug('image', 'OS::Glance::Image'); + expect(service.getTypeNameBySlug('image')).toBe('OS::Glance::Image'); + }); + describe('getName', function() { it('returns nothing if names not provided', function() { - var type = service.getResourceType('something', {}); + var type = service.getResourceType('something'); expect(type.getName(2)).toBeUndefined(); }); it('returns plural if count not provided', function() { - var type = service.getResourceType('something', - {names: ['Name', 'Names']}); + var type = service.getResourceType('something') + .setNames('Name', 'Names'); expect(type.getName()).toBe('Names'); }); it('returns singular if given one', function() { - var type = service.getResourceType('something', {names: ["Image", "Images"]}); + var type = service.getResourceType('something') + .setNames("Image", "Images"); expect(type.getName(1)).toBe('Image'); }); it('returns plural if given two', function() { - var type = service.getResourceType('something', {names: ["Image", "Images"]}); + var type = service.getResourceType('something') + .setNames("Image", "Images"); expect(type.getName(2)).toBe('Images'); }); }); + it('manages the tableColumns', function() { + var type = service.getResourceType('something'); + type.tableColumns.push({id: "im-an-id"}); + type.tableColumns.push({title: "im-a-title"}); + expect(type.getTableColumns()).toEqual([{id: "im-an-id", title: "im-an-id"}, + {title: "im-a-title"}]); + }); + + it('manages the globalActions', function() { + var typeA = service.getResourceType('a'); + var typeB = service.getResourceType('b'); + typeA.globalActions.push({id: "action-a"}); + typeB.globalActions.push({id: "action-b"}); + expect(service.getGlobalActions()).toEqual([{id: "action-a"}, {id: "action-b"}]); + }); + describe('functions the resourceType object', function() { var type; beforeEach(function() { type = service.getResourceType('something'); }); + it("sets a default path generator", function() { + expect(type.pathGenerator('hello')).toBe('hello'); + }); + + it("default load function returns a promise", function() { + expect(type.loadFunction()).toBeDefined(); + }); + + it("allows setting a list function", function() { + function list() { + return 'this would be a promise'; + } + type.setListFunction(list); + expect(type.listFunction()).toBe('this would be a promise'); + }); + + it("allows setting of a summary template URL", function() { + type.setSummaryTemplateUrl('/my/path.html'); + expect(type.summaryTemplateUrl).toBe('/my/path.html'); + expect(type.setSummaryTemplateUrl('/what')).toBe(type); + }); + it('itemName defaults to returning the name of an item', function() { var item = {name: 'MegaMan'}; expect(type.itemName(item)).toBe('MegaMan'); diff --git a/openstack_dashboard/static/app/app.module.js b/openstack_dashboard/static/app/app.module.js index a02ec4dd30..3cf137a15a 100644 --- a/openstack_dashboard/static/app/app.module.js +++ b/openstack_dashboard/static/app/app.module.js @@ -37,6 +37,7 @@ */ var horizonBuiltInModules = [ 'horizon.app.core', + 'horizon.app.resources', 'horizon.app.tech-debt', 'horizon.auth', 'horizon.framework' diff --git a/openstack_dashboard/static/app/core/core.module.js b/openstack_dashboard/static/app/core/core.module.js index f1e93c4ac9..7c367275d4 100644 --- a/openstack_dashboard/static/app/core/core.module.js +++ b/openstack_dashboard/static/app/core/core.module.js @@ -43,11 +43,7 @@ ], config) // NOTE: this will move into the correct module as that resource type // becomes available. For now there is no volumes module. - .constant('horizon.app.core.volumes.resourceType', VOLUME_RESOURCE_TYPE) - .run([ - 'horizon.framework.conf.resource-type-registry.service', - performRegistrations - ]); + .constant('horizon.app.core.volumes.resourceType', VOLUME_RESOURCE_TYPE); config.$inject = ['$provide', '$windowProvider', '$routeProvider']; @@ -61,71 +57,4 @@ }); } - function performRegistrations(registry) { - // The items in this long list of registrations should ideally placed into - // respective module declarations. However, until they are more fully - // fleshed out there's no reason to pollute the directory/file structure. - // As a model, the Images registration happens in the images module. - registry.getResourceType('OS::Glance::Metadef', { - names: [gettext('Metadata Definition'), gettext('Metadata Definitions')] - }); - registry.getResourceType('OS::Nova::Server', { - names: [gettext('Instance'), gettext('Instances')] - }); - registry.getResourceType('OS::Nova::Flavor', { - names: [gettext('Flavor'), gettext('Flavors')] - }); - registry.getResourceType('OS::Nova::Hypervisor', { - names: [gettext('Hypervisor'), gettext('Hypervisors')] - }); - registry.getResourceType('OS::Nova::Keypair', { - names: [gettext('Key Pair'), gettext('Key Pairs')] - }); - registry.getResourceType('OS::Designate::Zone', { - names: [gettext('DNS Domain'), gettext('DNS Domains')] - }); - registry.getResourceType('OS::Designate::RecordSet', { - names: [gettext('DNS Record'), gettext('DNS Records')] - }); - registry.getResourceType('OS::Cinder::Backup', { - names: [gettext('Volume Backup'), gettext('Volume Backups')] - }); - registry.getResourceType('OS::Cinder::Snapshot', { - names: [gettext('Volume Snapshot'), gettext('Volume Snapshots')] - }); - registry.getResourceType(VOLUME_RESOURCE_TYPE, { - names: [gettext('Volume'), gettext('Volumes')] - }); - registry.getResourceType('OS::Swift::Account', { - names: [gettext('Object Account'), gettext('Object Accounts')] - }); - registry.getResourceType('OS::Swift::Container', { - names: [gettext('Object Container'), gettext('Object Containers')] - }); - registry.getResourceType('OS::Swift::Object', { - names: [gettext('Object'), gettext('Objects')] - }); - registry.getResourceType('OS::Neutron::HealthMonitor', { - names: [gettext('Network Health Monitor'), gettext('Network Health Monitors')] - }); - registry.getResourceType('OS::Neutron::Net', { - names: [gettext('Network'), gettext('Networks')] - }); - registry.getResourceType('OS::Neutron::Pool', { - names: [gettext('Load Balancer Pool'), gettext('Load Balancer Pools')] - }); - registry.getResourceType('OS::Neutron::PoolMember', { - names: [gettext('Load Balancer Pool Member'), gettext('Load Balancer Pool Members')] - }); - registry.getResourceType('OS::Neutron::Port', { - names: [gettext('Network Port'), gettext('Network Ports')] - }); - registry.getResourceType('OS::Neutron::Router', { - names: [gettext('Network Router'), gettext('Network Routers')] - }); - registry.getResourceType('OS::Neutron::Subnet', { - names: [gettext('Network Subnet'), gettext('Network Subnets')] - }); - } - })(); diff --git a/openstack_dashboard/static/app/resources/resources.module.js b/openstack_dashboard/static/app/resources/resources.module.js new file mode 100644 index 0000000000..44e6aa4557 --- /dev/null +++ b/openstack_dashboard/static/app/resources/resources.module.js @@ -0,0 +1,84 @@ +/* + * (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 + * @name horizon.app.resources + * @description + * + * # horizon.app.resources + * + * This module hosts registered resource types. This module file may + * contain individual registrations, or may have sub-modules that + * more fully contain registrations. + */ + angular + .module('horizon.app.resources', []) + .run(performRegistrations); + + performRegistrations.$inject = [ + 'horizon.framework.conf.resource-type-registry.service' + ]; + + function performRegistrations(registry) { + // The items in this long list of registrations should ideally placed into + // respective module declarations. However, until they are more fully + // fleshed out there's no reason to pollute the directory/file structure. + // As a model, the Images registration happens in the images module. + registry.getResourceType('OS::Glance::Metadef') + .setNames(gettext('Metadata Definition'), gettext('Metadata Definitions')); + registry.getResourceType('OS::Nova::Server') + .setNames(gettext('Instance'), gettext('Instances')); + registry.getResourceType('OS::Nova::Flavor') + .setNames(gettext('Flavor'), gettext('Flavors')); + registry.getResourceType('OS::Nova::Hypervisor') + .setNames(gettext('Hypervisor'), gettext('Hypervisors')); + registry.getResourceType('OS::Nova::Keypair') + .setNames(gettext('Key Pair'), gettext('Key Pairs')); + registry.getResourceType('OS::Designate::Zone') + .setNames(gettext('DNS Domain'), gettext('DNS Domains')); + registry.getResourceType('OS::Designate::RecordSet') + .setNames(gettext('DNS Record'), gettext('DNS Records')); + registry.getResourceType('OS::Cinder::Backup') + .setNames(gettext('Volume Backup'), gettext('Volume Backups')); + registry.getResourceType('OS::Cinder::Snapshot') + .setNames(gettext('Volume Snapshot'), gettext('Volume Snapshots')); + registry.getResourceType('OS::Cinder::Volume') + .setNames(gettext('Volume'), gettext('Volumes')); + registry.getResourceType('OS::Swift::Account') + .setNames(gettext('Object Account'), gettext('Object Accounts')); + registry.getResourceType('OS::Swift::Container') + .setNames(gettext('Object Container'), gettext('Object Containers')); + registry.getResourceType('OS::Swift::Object') + .setNames(gettext('Object'), gettext('Objects')); + registry.getResourceType('OS::Neutron::HealthMonitor') + .setNames(gettext('Network Health Monitor'), gettext('Network Health Monitors')); + registry.getResourceType('OS::Neutron::Net') + .setNames(gettext('Network'), gettext('Networks')); + registry.getResourceType('OS::Neutron::Pool') + .setNames(gettext('Load Balancer Pool'), gettext('Load Balancer Pools')); + registry.getResourceType('OS::Neutron::PoolMember') + .setNames(gettext('Load Balancer Pool Member'), gettext('Load Balancer Pool Members')); + registry.getResourceType('OS::Neutron::Port') + .setNames(gettext('Network Port'), gettext('Network Ports')); + registry.getResourceType('OS::Neutron::Router') + .setNames(gettext('Network Router'), gettext('Network Routers')); + registry.getResourceType('OS::Neutron::Subnet') + .setNames(gettext('Network Subnet'), gettext('Network Subnets')); + } +})();