Adding resource panel and table features

This patch adds two basic features: a directive that takes in a resource type
name and produces a table of the resource type with actions, links to views,
etc., based on information in the resource type object; the other directive
provides a resource panel with header based on a resource type name.

Change-Id: Idaba844aca5fc6e89e2bd7c65cb836feaba67f67
Partially-Implements: blueprint angular-registry
This commit is contained in:
Matt Borland 2016-05-18 08:05:43 -06:00
parent 947b9f5b42
commit 01aa99473a
13 changed files with 510 additions and 22 deletions

View File

@ -94,7 +94,9 @@
// 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.listFunction = function def() {
return Promise.resolve({data: {items: []}});
};
this.setListFunction = setListFunction;
// The table columns are an extensible registration of columns of data
@ -506,10 +508,6 @@
}
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 = {
@ -519,20 +517,9 @@
setDefaultSummaryTemplateUrl: setDefaultSummaryTemplateUrl,
getDefaultSummaryTemplateUrl: getDefaultSummaryTemplateUrl,
setDefaultDetailsTemplateUrl: setDefaultDetailsTemplateUrl,
getDefaultDetailsTemplateUrl: getDefaultDetailsTemplateUrl,
setSlug: setSlug,
getTypeNameBySlug: getTypeNameBySlug
getDefaultDetailsTemplateUrl: getDefaultDetailsTemplateUrl
};
function getTypeNameBySlug(slug) {
return slugs[slug];
}
function setSlug(slug, typeName) {
slugs[slug] = typeName;
return this;
}
function getDefaultSummaryTemplateUrl() {
return defaultSummaryTemplateUrl;
}

View File

@ -166,11 +166,6 @@
});
});
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');

View File

@ -0,0 +1,34 @@
/*
* (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.panel')
.controller('horizon.framework.widgets.panel.HzResourcePanelController', controller);
controller.$inject = [
'horizon.framework.conf.resource-type-registry.service'
];
function controller(registry) {
var ctrl = this;
ctrl.resourceType = registry.getResourceType(ctrl.resourceTypeName);
ctrl.pageName = ctrl.resourceType.getName();
}
})();

View File

@ -0,0 +1,57 @@
/*
* (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.
*/
(function() {
'use strict';
describe('hz-resource-panel controller', function() {
var ctrl;
var resourceType = {
getName: function() {
return 'MyType';
}
};
beforeEach(module('horizon.framework.conf'));
beforeEach(module('horizon.framework.widgets.panel'));
beforeEach(inject(function($controller) {
var registry = {
getResourceType: angular.noop
};
spyOn(registry, 'getResourceType').and.returnValue(resourceType);
ctrl = $controller('horizon.framework.widgets.panel.HzResourcePanelController', {
'horizon.framework.conf.resource-type-registry.service': registry,
tableResourceType: 'OS::Test::Example'});
}));
it('exists', function() {
expect(ctrl).toBeDefined();
});
it('sets resourceType to the resource type', function() {
expect(ctrl.resourceType).toBe(resourceType);
});
it('sets resourceTypeName to the resource type name', function() {
expect(ctrl.pageName).toEqual('MyType');
});
});
})();

View File

@ -0,0 +1,57 @@
/**
* (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.panel')
.directive('hzResourcePanel', directive);
directive.$inject = ['horizon.framework.widgets.basePath'];
/**
* @ngdoc directive
* @name hzResourcePanel
* @description
* This directive takes in a resource type name, e.g. 'OS::Glance::Image'
* as a String and produces the shell of a panel for that given resource
* type. This primarily includes a header and allows content to be
* transcluded.
*
* @example
```
<hz-resource-panel resource-type-name="OS::Nova::Server">
<div>Here is my content!</div>
<hz-resource-table resource-type-name="OS::Nova::Server"></hz-resource-table>
</hz-resource-panel>
```
*/
function directive(basePath) {
var directive = {
restrict: 'E',
scope: {
resourceTypeName: '@'
},
transclude: true,
bindToController: true,
templateUrl: basePath + 'panel/hz-resource-panel.html',
controller: "horizon.framework.widgets.panel.HzResourcePanelController as ctrl"
};
return directive;
}
})();

View File

@ -0,0 +1,4 @@
<div>
<hz-page-header header="{$ ctrl.pageName $}"></hz-page-header>
<ng-transclude></ng-transclude>
</div>

View File

@ -0,0 +1,22 @@
/*
* (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.panel', []);
})();

View File

@ -0,0 +1,126 @@
/*
* (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.table')
.controller('horizon.framework.widgets.table.ResourceTableController', controller);
controller.$inject = [
'$q',
'$scope',
'horizon.framework.util.actions.action-result.service',
'horizon.framework.conf.resource-type-registry.service'
];
function controller($q, $scope, actionResultService, registry) {
var ctrl = this;
// 'Public' Controller members
ctrl.resourceType = registry.getResourceType(ctrl.resourceTypeName);
ctrl.items = [];
ctrl.itemsSrc = [];
ctrl.searchFacets = [];
ctrl.config = {
detailsTemplateUrl: ctrl.resourceType.summaryTemplateUrl,
selectAll: true,
expand: true,
trackId: 'id',
searchColumnSpan: 6,
actionColumnSpan: 6,
columns: ctrl.resourceType.getTableColumns()
};
ctrl.batchActions = ctrl.resourceType.globalActions
.concat(ctrl.resourceType.batchActions);
ctrl.actionResultHandler = actionResultHandler;
// Controller Initialization/Loading
ctrl.resourceType.listFunction().then(onLoad);
registry.initActions(ctrl.resourceType.type, $scope);
// Local functions
function onLoad(response) {
ctrl.itemsSrc = response.data.items;
}
function actionResultHandler(returnValue) {
return $q.when(returnValue, actionSuccessHandler);
}
function actionSuccessHandler(result) { // eslint-disable-line no-unused-vars
// The action has completed (for whatever "complete" means to that
// action. Notice the view doesn't really need to know the semantics of the
// particular action because the actions return data in a standard form.
// That return includes the id and type of each created, updated, deleted
// and failed item.
//
// This handler is also careful to check the type of each item. This
// is important because actions which create non-items are launched from
// the items page (like create "volume" from image).
var deletedIds, updatedIds, createdIds, failedIds;
if ( result ) {
// Reduce the results to just item ids ignoring other types the action
// may have produced
deletedIds = actionResultService.getIdsOfType(result.deleted, ctrl.resourceType.type);
updatedIds = actionResultService.getIdsOfType(result.updated, ctrl.resourceType.type);
createdIds = actionResultService.getIdsOfType(result.created, ctrl.resourceType.type);
failedIds = actionResultService.getIdsOfType(result.failed, ctrl.resourceType.type);
// Handle deleted items
if (deletedIds.length) {
ctrl.itemsSrc = difference(ctrl.itemsSrc, deletedIds,'id');
}
// Handle updated and created items
if ( updatedIds.length || createdIds.length ) {
// Ideally, get each created item individually, but
// this is simple and robust for the common use case.
// TODO: If we want more detailed updates, we could do so here.
ctrl.resourceType.listFunction().then(onLoad);
}
// Handle failed items
if (failedIds.length) {
// Do nothing for now. Please note, actions may (and probably
// should) provide toast messages when something goes wrong.
}
} else {
// promise resolved, but no result returned. Because the action didn't
// tell us what happened...reload the displayed items just in case.
ctrl.resourceType.listFunction().then(onLoad);
}
}
function difference(currentList, otherList, key) {
return currentList.filter(filter);
function filter(elem) {
return otherList.filter(function filterDeletedItem(deletedItem) {
return deletedItem === elem[key];
}).length === 0;
}
}
}
})();

View File

@ -0,0 +1,122 @@
/*
* (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.
*/
(function() {
'use strict';
describe('hz-generic-table controller', function() {
var ctrl, listFunctionDeferred, $timeout, actionResultDeferred;
beforeEach(module('horizon.framework.util'));
beforeEach(module('horizon.framework.conf'));
beforeEach(module('horizon.framework.widgets.table'));
var resourceType = {
type: 'OS::Test::Example',
getTableColumns: angular.noop,
listFunction: angular.noop,
globalActions: [],
batchActions: []
};
beforeEach(inject(function($controller, $q, _$timeout_) {
$timeout = _$timeout_;
var registry = {
getTypeNameBySlug: angular.noop,
getResourceType: angular.noop,
initActions: angular.noop
};
listFunctionDeferred = $q.defer();
actionResultDeferred = $q.defer();
spyOn(resourceType, 'listFunction').and.returnValue(listFunctionDeferred.promise);
spyOn(registry, 'getResourceType').and.returnValue(resourceType);
ctrl = $controller('horizon.framework.widgets.table.ResourceTableController', {
$scope: {},
'horizon.framework.conf.resource-type-registry.service': registry},
{resourceTypeName: 'OS::Test::Example'});
}));
it('exists', function() {
expect(ctrl).toBeDefined();
});
it('sets itemsSrc to the response data', function() {
listFunctionDeferred.resolve({data: {items: [1,2,3]}});
$timeout.flush();
expect(ctrl.itemsSrc).toEqual([1,2,3]);
});
describe('actionResultHandler', function() {
beforeEach(function() {
ctrl.itemsSrc = [{type: 'Something', id: -1}, {type: 'OS::Test::Example', id: 1}];
});
it('handles deleted items', function() {
actionResultDeferred.resolve({deleted: [{type: 'ignored', id: 0},
{type: 'OS::Test::Example', id: 1}]});
var promise = ctrl.actionResultHandler(actionResultDeferred.promise);
promise.then(function() {
expect(ctrl.itemsSrc).toEqual([{type: 'Something', id: -1}]);
});
$timeout.flush();
});
it('handles updated items', function() {
actionResultDeferred.resolve({updated: [{type: 'OS::Test::Example', id: 1}]});
var promise = ctrl.actionResultHandler(actionResultDeferred.promise);
resourceType.listFunction.calls.reset();
promise.then(function() {
expect(resourceType.listFunction).toHaveBeenCalled();
});
$timeout.flush();
});
it('handles created items', function() {
actionResultDeferred.resolve({created: [{type: 'OS::Test::Example', id: 1}]});
var promise = ctrl.actionResultHandler(actionResultDeferred.promise);
resourceType.listFunction.calls.reset();
promise.then(function() {
expect(resourceType.listFunction).toHaveBeenCalled();
});
$timeout.flush();
});
it('handles failed items', function() {
actionResultDeferred.resolve({failed: [{type: 'OS::Test::Example', id: 1}]});
var promise = ctrl.actionResultHandler(actionResultDeferred.promise);
resourceType.listFunction.calls.reset();
promise.then(function() {
expect(resourceType.listFunction).not.toHaveBeenCalled();
});
$timeout.flush();
});
it('handles falsy results', function() {
actionResultDeferred.resolve(false);
var promise = ctrl.actionResultHandler(actionResultDeferred.promise);
resourceType.listFunction.calls.reset();
promise.then(function() {
expect(resourceType.listFunction).toHaveBeenCalled();
});
$timeout.flush();
});
});
});
})();

View File

@ -0,0 +1,59 @@
/**
* (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.table')
.directive('hzResourceTable', directive);
directive.$inject = ['horizon.framework.widgets.basePath'];
/**
* @ngdoc directive
* @name hzResourceTable
* @description
* This directive produces a table and accompanying components that describe
* a list of resources of the given type. Based on information in the
* registry, the batch, global, and item-level actions are presented as
* appropriate. Search capabilities are also provided. The table contents
* are responsive to actions' promise resolutions, updating contents when
* they are likely to have changed. This directive allows for the rapid
* development of standard resource tables without having to rewrite
* boilerplate controllers, markup, etc.
* @example
```
<div>Here's some content above the table.</div>
<hz-resource-table resource-type-name="OS::Cinder::Volume"></hz-resource-table>
<div>Here's some content below the table.</div>
```
*/
function directive(basePath) {
var directive = {
restrict: 'E',
scope: {
resourceTypeName: '@'
},
bindToController: true,
templateUrl: basePath + 'table/hz-resource-table.html',
controller: "horizon.framework.widgets.table.ResourceTableController as ctrl"
};
return directive;
}
})();

View File

@ -0,0 +1,10 @@
<hz-dynamic-table
table="ctrl"
config="ctrl.config"
items="ctrl.itemsSrc"
item-actions="ctrl.resourceType.itemActions"
client-full-text-search="true"
batch-actions="ctrl.batchActions"
filter-facets="ctrl.searchFacets"
result-handler="ctrl.actionResultHandler"
></hz-dynamic-table>

View File

@ -26,6 +26,7 @@
'horizon.framework.widgets.table',
'horizon.framework.widgets.modal',
'horizon.framework.widgets.modal-wait-spinner',
'horizon.framework.widgets.panel',
'horizon.framework.widgets.transfer-table',
'horizon.framework.widgets.charts',
'horizon.framework.widgets.action-list',

View File

@ -0,0 +1,14 @@
---
prelude: >
Angular components now exist to provide simple-to-
configure panels and tables, based off of registry
information about resources (e.g. Instances).
features:
- The hz-resource-table directive takes in a Heat
resource name (e.g. 'OS::Nova::Server') and uses
the Angular registry to provide actions, columns,
and summary views.
- The hz-resource-panel directive takes in a Heat
resource name (e.g. 'OS::Nova::Server') and
displays an appropriate header and allows content
to be transcluded to build the panel page.