Merge "Allow wiring of <hz-dynamic-table> into <transfer-table>"

This commit is contained in:
Jenkins 2016-07-27 14:13:23 +00:00 committed by Gerrit Code Review
commit 5401d9245a
14 changed files with 419 additions and 116 deletions

View File

@ -46,7 +46,7 @@ module.exports = function (config) {
// NOTE: the templates must also be listed in the files section below.
'./**/*.html': ['ng-html2js'],
// Used to indicate files requiring coverage reports.
'./**/!(*.spec).js': ['coverage']
'./**/!(*.spec|*.borrowed-from-underscore).js': ['coverage']
},
// Sets up module to process templates.

View File

@ -0,0 +1,93 @@
/**
* Copyright 2016, Mirantis, Inc.
*
* 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.util.filters')
.factory('horizon.framework.util.filters.$memoize', $memoize);
/**
* @ngdoc factory
* @name horizon.framework.util.filters.$memoize
* @module horizon.framework.util.filters
* @kind function
* @description
*
* Provides a decorator service to memoize results of function calls.
*
*/
function $memoize() {
/**
* Memoizes a given function by caching the computed result. Useful for
* speeding up slow-running computations. If passed an optional hashFunction,
* it will be used to compute the hash key for storing the result, based on
* the arguments to the original function. The default hashFunction just uses
* the first argument to the memoized function as the key. The cache of
* memoized values is available as the cache property on the returned
* function.
*
* @param {function} func
* The function calls to which are need to be memoized (i.e., cached).
*
* @param {function} hasher
* Function which is used to calculate a key under which the memoized result
* is stored in cache. Can be omitted for functions that take only a single
* argument of a scalar type (string, number, boolean). For any function that
* takes at least one argument of {array} or {object} type, or more than one
* argument providing this function is crucial. Hasher function should
* provide unique keys for a set of input arguments which produce unique
* output.
*
* @return {function}
* The decorated version of function func, which calls are cached.
*
* @example
* ```
* function getFactorials(numbers) {
* if (!angular.isArray(numbers)) {
* return 0;
* } else {
* return numbers.map(function(number) {
* var acc = 1;
* for (var n = number; n > 0; n--) {
* acc *= n;
* }
* return acc;
* }
* }
* }
*
* function hasher(numbers) {
* return numbers.join(',')
* }
*
* var memoizedGetFactorials = $memoize(getFactorials, hasher);
*/
return function(func, hasher) {
var memoize = function(key) {
var cache = memoize.cache;
var address = '' + (hasher ? hasher.apply(this, arguments) : key);
if (!cache.hasOwnProperty(address)) {
cache[address] = func.apply(this, arguments);
}
return cache[address];
};
memoize.cache = {};
return memoize;
};
}
}());

View File

@ -48,6 +48,8 @@
* selectAll {boolean} set to true if you want to enable select all checkbox
* expand {boolean} set to true if you want to inline details
* trackId {string} passed into ngRepeat's track by to identify objects
* noItemsMessage {string} message to be displayed when the table is empty. If
* not provided, the default message is used.
* columns {Array} of objects to describe each column. Each object
* requires: 'id', 'title', 'priority' (responsive priority when table resized)
* optional: 'sortDefault', 'filters' (to apply to the column cells),
@ -103,16 +105,20 @@
resultHandler: '=?'
},
templateUrl: basePath + 'table/hz-dynamic-table.html',
link: link
link: {
pre: preLink,
post: postLink
}
};
return directive;
function link(scope) {
function preLink(scope) {
scope.items = [];
}
function postLink(scope) {
// if selectAll and expand are not set in the config, default set to true
if (angular.isUndefined(scope.config.selectAll)) {
scope.config.selectAll = true;
}

View File

@ -93,7 +93,7 @@
</hz-detail-row>
</td>
</tr>
<tr hz-no-items items="items"></tr>
<tr hz-no-items message="config.noItemsMessage" items="items"></tr>
</tbody>
<!--

View File

@ -123,6 +123,19 @@
expect($element.find('tbody tr:eq(2) td:eq(4)').text()).toContain('mice');
});
it('displays the default no items message if noItemsMessage is not set', function() {
$scope.safeTableData = [];
var $element = digestMarkup($scope, $compile, markup);
expect($element.find('tbody td.no-rows-help').text()).toBe('No items to display.');
});
it('displays a custom no items message if noItemsMessage is provided', function() {
$scope.safeTableData = [];
$scope.config.noItemsMessage = 'A sample message';
var $element = digestMarkup($scope, $compile, markup);
expect($element.find('tbody td.no-rows-help').text()).toBe('A sample message');
});
it('has no search or action buttons if none configured', function() {
var $element = digestMarkup($scope, $compile, markup);
expect($element.find('.hz-dynamic-table-preamble').length).toBe(1);

View File

@ -0,0 +1,112 @@
/**
* Copyright 2016, Mirantis, Inc.
*
* 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.transfer-table')
.filter('filterAvailable', filterAvailable);
filterAvailable.$inject = ['horizon.framework.util.filters.$memoize'];
/**
* @ngdoc filter
* @name filterAvailable
*
* @param {array} available
* List of objects being filtered.
*
* @param {object} allocatedKeys
* Dictionary with object keys that should be excluded from filtered output.
*
* @param {string} primaryKey (Optional)
* Attribute name to use as primary key, defaults to 'id'.
*
* @returns {array}
* Filtered list of objects whose keys are NOT present in the dictionary.
*
* @description
*
* The filter works nicely when used inside ng-repeat directive and does not
* lead to an infinite digest loop error, thanks to memoizing its results on
* both the initial list, keys dictionary and key name. For more details see
* http://stackoverflow.com/a/24213626/4414610
* Since the filter cache is shared between filter invocations in different
* contexts, one must namespace entities key values. Consider the following
* example: there are, two security groups with keys 'test1' and 'test2' and
* two key pairs with same keys, the combined key for both datasets will be
* the same. Consequently we will get key pairs in a filter output while
* expecting security groups, or vice versa. To avoid subtle bugs like that
* entity keys must be namespaced; a good id of a key pair from Launch Instance
* wizard transfer table would be 'li_keypair:<keypair_name>'.
*
* @example
*
* var available = [{
* id: 1,
* attr: 'one'
* }, {
* id: 2,
* attr: 'two'
* }, {
* id: 3,
* attr: 'three'
* }]
*
* console.log(filterAvailable(available, [1]))
* console.log(filterAvailable(available, ['one'], 'attr')) // same result as above
*
*/
function filterAvailable($memoize) {
return $memoize($filterAvailable, $hasher);
function $idKeyOrDefault(primaryKey) {
return primaryKey || 'id';
}
function arrayIsEmpty(array) {
return angular.isUndefined(array) || !array.length;
}
function emptyObj(obj) {
return angular.isUndefined(obj) || !Object.keys(obj).length;
}
function $hasher(available, allocatedIds, primaryKey) {
if (arrayIsEmpty(available)) {
return '';
}
primaryKey = $idKeyOrDefault(primaryKey);
var key = available.map(function(item) {
return item[primaryKey];
}).sort().join('_');
return key + '_' + Object.keys(allocatedIds).sort().join('_');
}
function $filterAvailable(available, allocatedKeys, primaryKey) {
if (arrayIsEmpty(available)) {
return [];
} else if (emptyObj(allocatedKeys)) {
return available;
}
primaryKey = $idKeyOrDefault(primaryKey);
return available.filter(function isItemAvailable(item) {
return !(item[primaryKey] in allocatedKeys);
});
}
}
})();

View File

@ -0,0 +1,89 @@
/**
* Copyright 2016, Mirantis, Inc.
*
* 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('filterAvailable filter', function () {
var filterAvailable;
var input = [{
id: 'one', content: 'item1'
}, {
id: 'two', content: 'item2'
}, {
id: 'three', content: 'item3'
}];
var ids = {
'two': true
};
var filtered = [{
id: 'one', content: 'item1'
}, {
id: 'three', content: 'item3'
}];
beforeEach(function() {
module('horizon.framework.widgets.transfer-table');
module('horizon.framework.util.filters');
inject(function(_$filter_) {
filterAvailable = _$filter_('filterAvailable');
});
});
it('is defined', function() {
expect(filterAvailable).toBeDefined();
});
it('returns an empty list for empty input', function() {
expect(filterAvailable([])).toEqual([]);
expect(filterAvailable(undefined)).toEqual([]);
});
it('returns the same list if ids dictionary is empty', function() {
expect(filterAvailable(input, {})).toEqual(input);
});
it('subsequent applications to the untouched output are idempotent', function() {
var output = filterAvailable(input, {});
expect(filterAvailable(output, {})).toBe(output);
});
it('id mentioned in a dictionary is removed from output', function() {
expect(filterAvailable(input, ids)).toEqual(filtered);
});
it('two successive calls with same args return the same value', function() {
var output = filterAvailable(input, ids);
expect(filterAvailable(input, ids)).toBe(output);
});
it('calls on the filtered output after the second call are idempotent', function() {
var output = filterAvailable(input, ids);
var output2 = filterAvailable(output, ids);
expect(output2).not.toBe(output);
expect(filterAvailable(output2, ids)).toBe(output2);
});
it('third argument changes ids dictionary interpretation', function() {
expect(filterAvailable(input, ids, 'content')).not.toEqual(filtered);
});
it('third argument default value is "id"', function() {
expect(filterAvailable(input, ids, 'id')).toEqual(filtered);
});
});
})();

View File

@ -30,7 +30,8 @@
'$log',
'horizon.framework.widgets.transfer-table.events',
'horizon.framework.widgets.transfer-table.helpText',
'horizon.framework.widgets.transfer-table.limits'
'horizon.framework.widgets.transfer-table.limits',
'horizon.framework.util.q.extensions'
];
/**
@ -67,7 +68,8 @@
$log,
events,
helpText,
limits
limits,
qExtensions
) {
var trModel = $parse($attrs.trModel)($scope);
var trHelpText = $parse($attrs.helpText)($scope);
@ -76,6 +78,7 @@
var ctrl = this;
ctrl.allocate = allocate;
ctrl.deallocate = deallocate;
ctrl.itemActions = getItemActions();
ctrl.toggleView = toggleView;
ctrl.updateAllocated = updateAllocated;
ctrl.numAllocated = numAllocated;
@ -97,6 +100,40 @@
//////////
function getItemActions() {
return [{
template: {
text: '',
actionClasses: 'fa fa-plus'
},
service: {
allowed: function allocationAllowed(item) {
var allocatable = item && !ctrl.allocatedIds[item.id];
return qExtensions.booleanAsPromise(allocatable);
},
perform: function performAllocation(item) {
allocate(item);
return qExtensions.booleanAsPromise(true);
}
}
}, {
template: {
text: '',
actionClasses: 'fa fa-minus'
},
service: {
allowed: function deallocationAllowed(item) {
var deallocatable = item && ctrl.allocatedIds[item.id];
return qExtensions.booleanAsPromise(deallocatable);
},
perform: function performDeallocation(item) {
deallocate(item);
return qExtensions.booleanAsPromise(true);
}
}
}];
}
function init(model) {
if (!angular.isArray(model.available)) {

View File

@ -69,14 +69,14 @@
allocatedScope.$sourceItems = ctrl.allocated.sourceItems;
allocatedScope.$isAllocatedTable = true;
transclude(allocatedScope, function(clone) {
allocated.append(clone.filter('table'));
allocated.append(extractClonableTable(clone));
});
var availableScope = scope.$new();
availableScope.$displayedItems = ctrl.available.displayedItems;
availableScope.$sourceItems = ctrl.available.sourceItems;
availableScope.$isAvailableTable = true;
transclude(availableScope, function(clone) {
available.append(clone.filter('table'));
available.append(extractClonableTable(clone));
});
} else {
transclude(scope, function(clone) {
@ -84,6 +84,26 @@
available.append(clone.filter('available'));
});
}
/**
* Finds in a given DOM either an <hz-dynamic-table> internal node or (as a
* fallback) a <table> node.
*
* @param {object} element
* The jqLite/jQuery wrapper around DOM node where the table node is
* being searched.
*
* @return {object}
* The jqLite/jQuery wrapper around the table DOM node that was found.
*/
function extractClonableTable(element) {
var table = element.filter('hz-dynamic-table');
if (!table.length) {
table = element.filter('table');
}
return table;
}
}
}
})();

View File

@ -1,6 +1,6 @@
<dl class="key-pair-details">
<dt translate>Public Key</dt>
<dd>
<pre><code>{$ row.public_key $}</code></pre>
<pre><code>{$ item.public_key $}</code></pre>
</dd>
</dl>

View File

@ -62,12 +62,32 @@
ctrl.tableData = {
available: launchInstanceModel.keypairs,
allocated: launchInstanceModel.newInstanceSpec.key_pair,
displayedAvailable: [],
displayedAllocated: []
allocated: launchInstanceModel.newInstanceSpec.key_pair
};
ctrl.tableDetails = basePath + 'keypair/keypair-details.html';
ctrl.availableTableConfig = {
selectAll: false,
trackId: 'id',
detailsTemplateUrl: basePath + 'keypair/keypair-details.html',
columns: [
{id: 'name', title: gettext('Name'), priority: 1},
{id: 'fingerprint', title: gettext('Fingerprint'), priority: 2}
]
};
ctrl.allocatedTableConfig = angular.copy(ctrl.availableTableConfig);
ctrl.allocatedTableConfig.noItemsMessage = gettext(
'Select a key pair from the available key pairs below.');
ctrl.filterFacets = [{
label: gettext('Name'),
name: 'name',
singleton: true
}, {
label: gettext('Fingerprint'),
name: 'fingerprint',
singleton: true
}];
ctrl.tableLimits = {
maxAllocation: 1

View File

@ -66,15 +66,17 @@
it('sets table data to appropriate scoped items', function() {
expect(ctrl.tableData).toBeDefined();
expect(Object.keys(ctrl.tableData).length).toBe(4);
expect(Object.keys(ctrl.tableData).length).toBe(2);
expect(ctrl.tableData.available).toEqual([{name: 'key1'}, {name: 'key2'}]);
expect(ctrl.tableData.allocated).toEqual(['key1']);
expect(ctrl.tableData.displayedAvailable).toEqual([]);
expect(ctrl.tableData.displayedAllocated).toEqual([]);
});
it('defines table details template', function() {
expect(ctrl.tableDetails).toBeDefined();
expect(ctrl.availableTableConfig.detailsTemplateUrl).toBeDefined();
});
it('defines a custom no items message for allocated table', function() {
expect(ctrl.allocatedTableConfig.noItemsMessage).toBeDefined();
});
it('allows allocation of only one', function() {

View File

@ -27,102 +27,13 @@
<translate>Import Key Pair</translate>
</button>
<transfer-table tr-model="ctrl.tableData"
limits="ctrl.tableLimits">
<!-- Key Pairs Allocated-->
<allocated validate-number-min="ctrl.isKeypairRequired" ng-model="ctrl.tableData.allocated.length">
<table st-table="ctrl.tableData.displayedAllocated"
st-safe-src="ctrl.tableData.allocated" hz-table
class="table table-striped table-rsp table-detail">
<thead>
<tr>
<th class="expander"></th>
<th class="rsp-p1" translate>Name</th>
<th class="rsp-p2" translate>Fingerprint</th>
<th></th>
</tr>
</thead>
<tbody>
<tr ng-if="ctrl.tableData.allocated.length === 0">
<td colspan="8">
<div class="no-rows-help" translate>
Select a key pair from the available key pairs below.
</div>
</td>
</tr>
<tr ng-repeat-start="row in ctrl.tableData.displayedAllocated track by row.id">
<td class="expander">
<span class="fa fa-chevron-right" hz-expand-detail
title="{$ ::trCtrl.helpText.expandDetailsText $}"></span>
</td>
<td class="rsp-p1">{$ row.name $}</td>
<td class="rsp-p2">{$ row.fingerprint $}</td>
<td class="actions_column">
<action-list>
<action action-classes="'btn btn-default'"
callback="trCtrl.deallocate" item="row">
<span class="fa fa-minus"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td class="detail" colspan="4" ng-include="ctrl.tableDetails"></td>
</tr>
</tbody>
</table>
</allocated>
<!-- Key Pairs Available -->
<available>
<table st-table="ctrl.tableData.displayedAvailable"
st-safe-src="ctrl.tableData.available"
hz-table class="table table-striped table-rsp table-detail">
<thead>
<tr>
<th class="search-header" colspan="7">
<hz-search-bar icon-classes="fa-search"></hz-search-bar>
</th>
</tr>
<tr>
<th class="expander"></th>
<th st-sort="name" st-sort-default class="rsp-p1" translate>Name</th>
<th st-sort="fingerprint" class="rsp-p1" translate>Fingerprint</th>
<th></th>
</tr>
</thead>
<tbody>
<tr ng-if="trCtrl.numAvailable() === 0">
<td colspan="8">
<div class="no-rows-help">
{$ ::trCtrl.helpText.noneAvailText $}
</div>
</td>
</tr>
<tr ng-repeat-start="row in ctrl.tableData.displayedAvailable track by row.id"
ng-if="!trCtrl.allocatedIds[row.id]">
<td class="expander">
<span class="fa fa-chevron-right" hz-expand-detail
title="{$ ::trCtrl.helpText.expandDetailsText $}"></span>
</td>
<td class="rsp-p1">{$ row.name$}</td>
<td class="rsp-p1">{$ row.fingerprint $}</td>
<td class="actions_column">
<action-list>
<action action-classes="'btn btn-default'"
callback="trCtrl.allocate" item="row">
<span class="fa fa-plus"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row" ng-if="!trCtrl.allocatedIds[row.id]">
<td class="detail" colspan="4" ng-include="ctrl.tableDetails">
</td>
</tr>
</tbody>
</table>
</available>
<transfer-table tr-model="ctrl.tableData" limits="ctrl.tableLimits" clone-content>
<hz-dynamic-table
config="$isAvailableTable ? ctrl.availableTableConfig : ctrl.allocatedTableConfig"
items="$isAvailableTable ? ($sourceItems | filterAvailable:trCtrl.allocatedIds) : $sourceItems"
item-actions="trCtrl.itemActions"
filter-facets="$isAvailableTable && ctrl.filterFacets"
table="ctrl">
</hz-dynamic-table>
</transfer-table> <!-- End Key Pairs Table -->
</div> <!-- End Controller -->

View File

@ -346,7 +346,7 @@
angular.extend(
model.keypairs,
data.data.items.map(function (e) {
e.keypair.id = e.keypair.name;
e.keypair.id = 'li_keypair:' + e.keypair.name;
return e.keypair;
}));
if (data.data.items.length === 1) {