Implements a filtered select

This patch implements a custom select in the load balancer creation/edit
form with the following features:

- The options are presented in a tabular form with: network name,
  network id, subnet name, subnet id
- An input text filter which filters across all fields

The select is implemented as a customizable AngularJS component, which
allows for any of the displayed information to be changed easily.

Change-Id: I6ff16cb8ffd0ebdb8c465e5197f90ba2939a28c1
Story: 2004347
Task: 27943
This commit is contained in:
mareklycka 2018-11-15 14:23:27 +01:00
parent cdf26e99c8
commit 35f6f90d0c
11 changed files with 1024 additions and 104 deletions

3
.gitignore vendored
View File

@ -63,5 +63,8 @@ ChangeLog
.ropeproject/
.DS_Store
# IntelliJ editors
.idea
# Conf
octavia_dashboard/conf

View File

@ -92,3 +92,35 @@
margin-top: 10px;
}
}
/* Filtering select widget */
.filter-select-options {
padding: 10px;
background-color: $dropdown-bg;
min-width: 100%;
thead {
th {
color: $dropdown-header-color;
}
}
tbody {
tr:hover {
color: $dropdown-link-hover-color;
background-color: $dropdown-link-hover-bg;
}
tr {
color:$dropdown-link-color;
.highlighted {
background-color: darken($dropdown-link-hover-bg, 15%);
}
.empty-options {
text-align: center;
font-style: italic;
}
}
}
}

View File

@ -0,0 +1,266 @@
/*
* 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 component
* @ngname horizon.dashboard.project.lbaasv2:filterSelect
*
* @param {function} onSelect callback invoked when a selection is made,
* receives the selected option as a parameter (required)
* @param {object} ng-model the currently selected option. Uses the ng-model
* directive to tie into angularjs validations (required)
* @param {function} shorthand a function used to create a summarizing text
* for an option object passed to it as the first parameter. This text is
* displayed in the filter input when an option is selected. (required)
* @param {boolean} disabled boolean value controlling the disabled state
* of the component (optional, defaults to false)
* @param {array} options a collection of objects to be presented for
* selection (required)
* @param {array} columns array of column defining objects. (required,
* see below for details)
* @param {boolean} loaded allows the control to be replaced by a loading bar
* if required (such as when waiting for data to be loaded) (optional,
* defaults to false)
*
* @description
* The filter-select component serves as a more complicated alternative to
* the standard select control.
*
* Options in this component are presented as a customizable table where
* each row corresponds to one of the options and allows for the presented
* options to be filtered using a text input.
*
* Columns of the table are defined through the `column` attribute, which
* accepts an array of column definition objects. Each object contains two
* properties: `label` and `value`.
*
* * label {string} specifies a text value used as the given columns header
*
* The displayed text in each column for every option is created by
* applying the `value` property of the given column definition to the
* option object. It can be of two types with different behaviors:
*
* * {string} describes the value as a direct property of option objects,
* using it as key into the option object
*
* * {function} defines a callback that is expected to return the desired
* text and receives the option as it's parameter
*
* @example
*
* $scope.options = [{
* text: "option 1",
* number: 1
* }, {
* text: "option 2",
* number: 2
* }]
* $scope.onSelect = function(option) { scope.value = option; };
* $scope.columns = [{
* label: "Column 1",
* value: "text"
* }, {
* label: "Column 2",
* value: function(option) { return option['number']; };
* }];
* $scope.shorthand = function(option) {
* return option['text'] + " => " + option['number'];
* };
*
* ```
* <filter-select
* onSelect="onSelect"
* options="options"
* columns="columns"
* shorthand="shorthand">
* </filter-select>
* ```
*
* The rendered table would then look as follows:
*
* | Column 1 | Column 2 |
* |----------|----------|
* | Option 1 | 1 |
* |----------|----------|
* | Option 2 | 2 |
*
* If the first option is selected, the shorthand function is invoked and
* the following is displayed in the input box: 'Option1 => 1'
*
*/
angular
.module('horizon.dashboard.project.lbaasv2')
.component('filterSelect', {
templateUrl: getTemplate,
controller: filterSelectController,
require: {
ngModelCtrl: "ngModel"
},
bindings: {
onSelect: '&',
shorthand: '<',
columns: '<',
options: '<',
disabled: '<',
loaded: '<',
ngModel: '<'
}
});
filterSelectController.$inject = ['$document', '$scope', '$element'];
function filterSelectController($document, $scope, $element) {
var ctrl = this;
ctrl._scope = $scope;
// Used to filter rows
ctrl.textFilter = '';
// Model for the filtering text input
ctrl.text = '';
// Model for the dropdown
ctrl.isOpen = false;
// Arrays of text to be displayed
ctrl.rows = [];
// Lifecycle methods
ctrl.$onInit = function() {
$document.on('click', ctrl.externalClick);
ctrl.loaded = ctrl._setValue(ctrl.loaded, true);
ctrl.disabled = ctrl._setValue(ctrl.disabled, false);
};
ctrl.$onDestroy = function() {
$document.off('click', ctrl.externalClick);
};
ctrl.$onChanges = function(changes) {
if (changes.ngModel && ctrl.options) {
var i = ctrl.options.indexOf(ctrl.ngModel);
if (i > -1) {
ctrl.textFilter = '';
ctrl.text = ctrl.shorthand(ctrl.ngModel);
}
}
ctrl._buildRows();
};
// Handles clicking outside of the comopnent
ctrl.externalClick = function(event) {
if (!$element.find(event.target).length) {
ctrl._setOpenExternal(false);
}
};
// Template handleres
ctrl.onTextChange = function() {
ctrl.onSelect({
option: null
});
ctrl.textFilter = ctrl.text;
//ctrl._rebuildRows();
ctrl._buildRows();
};
ctrl.togglePopup = function() {
ctrl.isOpen = !ctrl.isOpen;
};
ctrl.openPopup = function(event) {
event.stopPropagation();
ctrl.isOpen = true;
};
ctrl.selectOption = function(index) {
var option = ctrl.options[index];
ctrl.onSelect({
option: option
});
ctrl.isOpen = false;
};
// Internal/Helper methods
ctrl._buildCell = function(column, option) {
if (angular.isFunction(column.value)) {
return column.value(option);
} else {
return option[column.value];
}
};
ctrl._buildRow = function(option) {
var row = [];
var valid = false;
angular.forEach(ctrl.columns, function(column) {
var cell = ctrl._buildCell(column, option);
var split = ctrl._splitByFilter(cell);
valid = valid || split.wasSplit;
row.push(split.values);
});
if (valid || !ctrl.textFilter) {
return row;
} else {
return null;
}
};
ctrl._buildRows = function() {
ctrl.rows.length = 0;
angular.forEach(ctrl.options, function(option) {
var row = ctrl._buildRow(option);
if (row) {
ctrl.rows.push(row);
}
});
};
ctrl._splitByFilter = function(text) {
var split = {
values: [text, "", ""],
wasSplit: false
};
var i;
if (ctrl.textFilter && (i = text.indexOf(ctrl.textFilter)) > -1) {
split.values = [
text.substring(0, i),
ctrl.textFilter,
text.substring(i + ctrl.textFilter.length)
];
split.wasSplit = true;
}
return split;
};
ctrl._setOpenExternal = function(value) {
ctrl.isOpen = value;
$scope.$apply();
};
ctrl._isUnset = function(property) {
return angular.isUndefined(property) || property === null;
};
ctrl._setValue = function(property, defaultValue) {
return ctrl._isUnset(property) ? defaultValue : property;
};
}
getTemplate.$inject = ['horizon.dashboard.project.lbaasv2.basePath'];
function getTemplate(basePath) {
return basePath + 'widgets/filterselect/filter-select.html';
}
})();

View File

@ -0,0 +1,293 @@
/*
* 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('Filter-Select', function() {
var mockOptions, mockColumns;
beforeEach(function() {
mockOptions = [{
text: 'Option 1'
}, {
text: 'Option 2'
}];
mockColumns = [{
label: 'Key column',
value: 'text'
}, {
label: 'Function Column',
value: function(option) {
return option.text + ' extended';
}
}];
});
describe('component', function() {
var component, ctrl, child, scope, otherElement,
filterSelect, element;
beforeEach(module('templates'));
beforeEach(module('horizon.dashboard.project.lbaasv2',
function($provide) {
$provide.decorator('filterSelectDirective', function($delegate) {
component = $delegate[0];
spyOn(component, 'templateUrl').and.callThrough();
return $delegate;
});
}
));
beforeEach(inject(function($compile, $rootScope) {
scope = $rootScope.$new();
scope.ngModel = null;
scope.onSelect = function() {};
scope.shorthand = function(option) {
return 'Shorthand: ' + option.text;
};
scope.disabled = true;
scope.columns = mockColumns;
scope.options = mockOptions;
var html = '<filter-select ' +
'onSelect="onSelect" ' +
'ng-model="ngModel" ' +
'shorthand="shorthand" ' +
'disabled="disabled" ' +
'columns="columns" ' +
'options="options" ' +
'></filter-select>';
var parentElement = angular.element('<div></div>');
otherElement = angular.element('<div id="otherElement"></div>');
filterSelect = angular.element(html);
parentElement.append(otherElement);
parentElement.append(filterSelect);
element = $compile(parentElement)(scope);
scope.$apply();
child = element.find('input');
ctrl = filterSelect.controller('filter-select');
spyOn(ctrl, 'onSelect').and.callThrough();
spyOn(ctrl, '_buildRows').and.callThrough();
spyOn(ctrl, 'shorthand').and.callThrough();
spyOn(ctrl, '_setOpenExternal').and.callThrough();
}));
it('should load the correct template', function() {
expect(component.templateUrl).toHaveBeenCalled();
expect(component.templateUrl()).toBe(
'/static/dashboard/project/lbaasv2/' +
'widgets/filterselect/filter-select.html'
);
});
it('should react to value change', function() {
// Change one way binding for 'value'
scope.ngModel = mockOptions[0];
scope.$apply();
expect(ctrl.textFilter).toBe('');
expect(ctrl.text).toBe('Shorthand: Option 1');
expect(ctrl._buildRows).toHaveBeenCalled();
expect(ctrl.shorthand).toHaveBeenCalledWith(mockOptions[0]);
});
it('should react to non-option value', function() {
// Set one way binding to an impossible value
var nonOption = {};
scope.ngModel = nonOption;
scope.$apply();
expect(ctrl._buildRows).toHaveBeenCalled();
expect(ctrl.shorthand).not.toHaveBeenCalled();
});
it('should react to non-value change', function() {
// Set non-value binding and trigger onChange, make sure value related
// changes aren't triggered
scope.disabled = false;
scope.$apply();
expect(ctrl._buildRows).toHaveBeenCalled();
expect(ctrl.shorthand).not.toHaveBeenCalled();
});
it('should react to outside clicks', function() {
var mockChildEvent = {
target: child
};
ctrl.externalClick(mockChildEvent);
expect(ctrl._setOpenExternal).not.toHaveBeenCalled();
var mockOutsideEvent = {
target: otherElement
};
ctrl.externalClick(mockOutsideEvent);
expect(ctrl._setOpenExternal).toHaveBeenCalledWith(false);
});
it('should build rows', function() {
var expectedRows = [
[['Option 1', '', ''], ['Option 1 extended', '', '']],
[['Option 2', '', ''], ['Option 2 extended', '', '']]
];
expect(ctrl.rows).toEqual(expectedRows);
// filtered by text
ctrl.textFilter = '1';
ctrl._buildRows();
var expectedFiltered = [
[['Option ', '1', ''], ['Option ', '1', ' extended']]
];
expect(ctrl.rows).toEqual(expectedFiltered);
});
it('should build cells', function() {
// Test that normal string values are used as keys against options
var option1text = ctrl._buildCell(ctrl.columns[0], ctrl.options[0]);
expect(option1text).toBe('Option 1');
// Test that column value callbacks are called
spyOn(ctrl.columns[1], 'value');
ctrl._buildCell(ctrl.columns[1], ctrl.options[0]);
expect(ctrl.columns[1].value).toHaveBeenCalledWith(ctrl.options[0]);
});
it('should handle text changes', function() {
// Test input text changes
var mockInput = 'mock input text';
ctrl.text = mockInput;
ctrl.onTextChange();
expect(ctrl.textFilter).toEqual(mockInput);
expect(ctrl._buildRows).toHaveBeenCalled();
});
it('should select options', function() {
ctrl.selectOption(1);
expect(ctrl.onSelect).toHaveBeenCalledWith({
option: mockOptions[1]
});
expect(ctrl.isOpen).toBe(false);
});
});
describe('controller', function() {
var scope, ctrl;
beforeEach(module('horizon.dashboard.project.lbaasv2'));
beforeEach(
inject(
function($rootScope, $componentController) {
scope = $rootScope.$new();
ctrl = $componentController('filterSelect', {
$scope: scope,
$element: angular.element('<span></span>')
});
ctrl.$onInit();
spyOn(scope, '$apply');
}
)
);
it('should initialize and remove listeners', function() {
var events = $._data(document, 'events');
expect(events.click).toBeDefined();
expect(events.click.length).toBe(1);
expect(events.click[0].handler).toBe(ctrl.externalClick);
ctrl.$onDestroy();
expect(events.click).not.toBeDefined();
});
it('should initialize state', function() {
// Initial component state; simply bound values needn't be checked,
// angular binding is trusted
expect(ctrl.textFilter).toBe('');
expect(ctrl.text).toBe('');
expect(ctrl.isOpen).toBe(false);
});
it('should open popup', function() {
var mockEvent = {
stopPropagation: function() {}
};
spyOn(mockEvent, 'stopPropagation');
ctrl.openPopup(mockEvent);
expect(mockEvent.stopPropagation).toHaveBeenCalled();
expect(ctrl.isOpen).toBe(true);
});
it('should toggle popup', function() {
// not much to tests here; utilizes bootstrap dropdown
ctrl.togglePopup();
expect(ctrl.isOpen).toBe(true);
ctrl.togglePopup();
expect(ctrl.isOpen).toBe(false);
});
it('should set open state from outside the digest', function() {
ctrl._setOpenExternal(true);
expect(ctrl.isOpen).toBe(true);
expect(scope.$apply).toHaveBeenCalled();
ctrl._setOpenExternal(false);
expect(ctrl.isOpen).toBe(false);
expect(scope.$apply).toHaveBeenCalled();
});
it('should check unset values', function() {
expect(ctrl._isUnset(null)).toBe(true);
expect(ctrl._isUnset(undefined)).toBe(true);
expect(ctrl._isUnset('defined_value')).toBe(false);
});
it('should set default values correctly', function() {
var defaultValue = 'default value';
var realValue = 'input value';
var firstResult = ctrl._setValue(null, defaultValue);
expect(firstResult).toBe(defaultValue);
var secondResult = ctrl._setValue(realValue, defaultValue);
expect(secondResult).toBe(realValue);
});
it('should split by filter', function() {
ctrl.textFilter = 'matched';
var notSplit = ctrl._splitByFilter('does not match');
expect(notSplit).toEqual({
values:['does not match', '', ''],
wasSplit: false
});
var split = ctrl._splitByFilter('this matched portion');
expect(split).toEqual({
values: ['this ', 'matched', ' portion'],
wasSplit: true
});
});
});
});
})();

View File

@ -0,0 +1,43 @@
<div class="horizon-loading-bar" ng-if="!$ctrl.loaded">
<div class="progress progress-striped active">
<div class="progress-bar"></div>
</div>
</div>
<div uib-dropdown is-open="$ctrl.isOpen" auto-close="disabled" ng-if="$ctrl.loaded">
<div class="input-group">
<input type="text" ng-model="$ctrl.text" ng-change="$ctrl.onTextChange()"
class="form-control" ng-focus="$ctrl.openPopup($event)"
ng-disabled="$ctrl.disabled">
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle"
ng-click="$ctrl.togglePopup()" ng-disabled="$ctrl.disabled">
<span class="caret"></span>
</button>
</div>
</div>
<div uib-dropdown-menu ng-class="'filter-select-options'">
<table class="table" ng-if="$ctrl.loaded">
<thead>
<tr>
<th ng-repeat="column in $ctrl.columns track by $index" scope="col">
{$ column.label $}
</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="row in $ctrl.rows track by $index"
ng-if="$ctrl.rows.length > 0"
ng-click="$ctrl.selectOption($index)">
<td ng-repeat="column in row track by $index">
{$ column[0] $}<span ng-class="'highlighted'">{$ column[1] $}</span>{$ column[2] $}
</td>
</tr>
<tr ng-if="$ctrl.rows.length <= 0">
<td colspan="{$ $ctrl.columns.length $}" ng-class="'empty-options'">
<translate>No matching options</translate>
</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function () {
(function() {
'use strict';
angular
@ -22,7 +22,8 @@
LoadBalancerDetailsController.$inject = [
'horizon.dashboard.project.lbaasv2.patterns',
'horizon.framework.util.i18n.gettext'
'horizon.framework.util.i18n.gettext',
'$scope'
];
/**
@ -31,13 +32,12 @@
* @description
* The `LoadBalancerDetailsController` controller provides functions for
* configuring the load balancers step of the LBaaS wizard.
* @param patterns The LBaaS v2 patterns constant.
* @param gettext The horizon gettext function for translation.
* @returns undefined
* @param {object} patterns The LBaaS v2 patterns constant.
* @param {function} gettext The horizon gettext function for translation.
* @param {object} $scope Allows access to the model
* @returns {undefined} undefined
*/
function LoadBalancerDetailsController(patterns, gettext) {
function LoadBalancerDetailsController(patterns, gettext, $scope) {
var ctrl = this;
// Error text for invalid fields
@ -45,5 +45,81 @@
// IP address validation pattern
ctrl.ipPattern = [patterns.ipv4, patterns.ipv6].join('|');
// Defines columns for the subnet selection filtered pop-up
ctrl.subnetColumns = [{
label: gettext('Network'),
value: function(subnet) {
var network = $scope.model.networks[subnet.network_id];
return network ? network.name : '';
}
}, {
label: gettext('Network ID'),
value: 'network_id'
}, {
label: gettext('Subnet'),
value: 'name'
}, {
label: gettext('Subnet ID'),
value: 'id'
}, {
label: gettext('CIDR'),
value: 'cidr'
}];
ctrl.subnetOptions = [];
ctrl.shorthand = function(subnet) {
var network = $scope.model.networks[subnet.network_id];
var networkText = network ? network.name : subnet.network_id.substring(0, 10) + '...';
var cidrText = subnet.cidr;
var subnetText = subnet.name || subnet.id.substring(0, 10) + '...';
return networkText + ': ' + cidrText + ' (' + subnetText + ')';
};
ctrl.setSubnet = function(option) {
if (option) {
$scope.model.spec.loadbalancer.vip_subnet_id = option;
} else {
$scope.model.spec.loadbalancer.vip_subnet_id = null;
}
};
ctrl.dataLoaded = false;
ctrl._checkLoaded = function() {
if ($scope.model.initialized) {
ctrl.buildSubnetOptions();
ctrl.dataLoaded = true;
}
};
/*
The watchers in this component are a bit of a workaround for the way
data is loaded asynchornously in the model service. First data loads
are marked by a change of 'model.initialized' from false to true, which
should replace the striped loading bar with a functional dropdown.
Additional changes to networks and subnets have to be watched even after
first loads, however, as those changes need to be applied to the select
options
*/
ctrl.$onInit = function() {
$scope.$watchCollection('model.subnets', function() {
ctrl._checkLoaded();
});
$scope.$watchCollection('model.networks', function() {
ctrl._checkLoaded();
});
$scope.$watch('model.initialized', function() {
ctrl._checkLoaded();
});
};
ctrl.buildSubnetOptions = function() {
// Subnets are sliced to maintain data immutability
ctrl.subnetOptions = $scope.model.subnets.slice(0);
};
}
})();

View File

@ -22,10 +22,46 @@
beforeEach(module('horizon.dashboard.project.lbaasv2'));
describe('LoadBalancerDetailsController', function() {
var ctrl;
var ctrl, scope, mockSubnets;
beforeEach(inject(function($controller, $rootScope) {
mockSubnets = [{
id: '7262744a-e1e4-40d7-8833-18193e8de191',
network_id: '5d658cef-3402-4474-bb8a-0c1162efd9a9',
name: 'subnet_1',
cidr: '1.1.1.1/24'
}, {
id: 'd8056c7e-c810-4ee5-978e-177cb4154d81',
network_id: '12345678-0000-0000-0000-000000000000',
name: 'subnet_2',
cidr: '2.2.2.2/16'
}, {
id: 'd8056c7e-c810-4ee5-978e-177cb4154d81',
network_id: '12345678-0000-0000-0000-000000000000',
name: '',
cidr: '2.2.2.2/16'
}];
beforeEach(inject(function($controller) {
ctrl = $controller('LoadBalancerDetailsController');
scope = $rootScope.$new();
scope.model = {
networks: {
'5d658cef-3402-4474-bb8a-0c1162efd9a9': {
id: '5d658cef-3402-4474-bb8a-0c1162efd9a9',
name: 'network_1'
}
},
subnets: [{}, {}],
spec: {
loadbalancer: {
vip_subnet_id: null
}
},
initialized: false
};
ctrl = $controller('LoadBalancerDetailsController', {$scope: scope});
spyOn(ctrl, 'buildSubnetOptions').and.callThrough();
spyOn(ctrl, '_checkLoaded').and.callThrough();
}));
it('should define error messages for invalid fields', function() {
@ -36,6 +72,92 @@
expect(ctrl.ipPattern).toBeDefined();
});
it('should create shorthand text', function() {
// Full values
expect(ctrl.shorthand(mockSubnets[0])).toBe(
'network_1: 1.1.1.1/24 (subnet_1)'
);
// No network name
expect(ctrl.shorthand(mockSubnets[1])).toBe(
'12345678-0...: 2.2.2.2/16 (subnet_2)'
);
// No network and subnet names
expect(ctrl.shorthand(mockSubnets[2])).toBe(
'12345678-0...: 2.2.2.2/16 (d8056c7e-c...)'
);
});
it('should set subnet', function() {
ctrl.setSubnet(mockSubnets[0]);
expect(scope.model.spec.loadbalancer.vip_subnet_id).toBe(mockSubnets[0]);
ctrl.setSubnet(null);
expect(scope.model.spec.loadbalancer.vip_subnet_id).toBe(null);
});
it('should initialize watchers', function() {
ctrl.$onInit();
scope.model.subnets = [];
scope.$apply();
expect(ctrl._checkLoaded).toHaveBeenCalled();
scope.model.networks = {};
scope.$apply();
expect(ctrl._checkLoaded).toHaveBeenCalled();
scope.model.initialized = true;
scope.$apply();
expect(ctrl._checkLoaded).toHaveBeenCalled();
});
it('should initialize networks watcher', function() {
ctrl.$onInit();
scope.model.networks = {};
scope.$apply();
//expect(ctrl.buildSubnetOptions).toHaveBeenCalled();
});
it('should build subnetOptions', function() {
ctrl.buildSubnetOptions();
expect(ctrl.subnetOptions).not.toBe(scope.model.subnets);
expect(ctrl.subnetOptions).toEqual(scope.model.subnets);
});
it('should produce column data', function() {
expect(ctrl.subnetColumns).toBeDefined();
var networkLabel1 = ctrl.subnetColumns[0].value(mockSubnets[0]);
expect(networkLabel1).toBe('network_1');
var networkLabel2 = ctrl.subnetColumns[0].value(mockSubnets[1]);
expect(networkLabel2).toBe('');
expect(ctrl.subnetColumns[1].label).toBe('Network ID');
expect(ctrl.subnetColumns[1].value).toBe('network_id');
expect(ctrl.subnetColumns[2].label).toBe('Subnet');
expect(ctrl.subnetColumns[2].value).toBe('name');
expect(ctrl.subnetColumns[3].label).toBe('Subnet ID');
expect(ctrl.subnetColumns[3].value).toBe('id');
expect(ctrl.subnetColumns[4].label).toBe('CIDR');
expect(ctrl.subnetColumns[4].value).toBe('cidr');
});
it('should react to data being loaded', function() {
ctrl._checkLoaded();
expect(ctrl.buildSubnetOptions).not.toHaveBeenCalled();
expect(ctrl.dataLoaded).toBe(false);
scope.model.initialized = true;
ctrl._checkLoaded();
expect(ctrl.buildSubnetOptions).toHaveBeenCalled();
expect(ctrl.dataLoaded).toBe(true);
});
});
});
})();

View File

@ -11,18 +11,6 @@
</div>
</div>
<div class="col-xs-12 col-sm-8 col-md-6">
<div class="form-group">
<label translate class="control-label" for="description">Description</label>
<input name="description" id="description" type="text" class="form-control"
ng-model="model.spec.loadbalancer.description">
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12 col-sm-8 col-md-6">
<div class="form-group"
ng-class="{ 'has-error': loadBalancerDetailsForm.ip.$invalid && loadBalancerDetailsForm.ip.$dirty }">
@ -35,25 +23,43 @@
</span>
</div>
</div>
<div class="col-xs-12 col-sm-8 col-md-6">
<div class="form-group required">
<label class="control-label" for="subnet">
<translate>Subnet</translate>
<span class="hz-icon-required fa fa-asterisk"></span>
</label>
<select class="form-control" name="subnet" id="subnet"
ng-options="subnet.name || subnet.id for subnet in model.subnets"
ng-model="model.spec.loadbalancer.vip_subnet_id" ng-required="true"
ng-disabled="model.context.id">
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12 col-sm-12 col-md-12">
<div class="form-group">
<label translate class="control-label" for="description">Description</label>
<input name="description" id="description" type="text" class="form-control"
ng-model="model.spec.loadbalancer.description">
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12 col-sm-12 col-md-12">
<div class="form-group">
<label class="control-label">
<translate>Subnet</translate>
<span class="hz-icon-required fa fa-asterisk"></span>
</label>
<!-- value="model.spec.loadbalancer.vip_subnet_id" -->
<filter-select
shorthand="ctrl.shorthand"
on-select="ctrl.setSubnet(option)"
disabled="model.context.id"
columns="ctrl.subnetColumns"
options="ctrl.subnetOptions"
loaded="ctrl.dataLoaded"
ng-required="true"
ng-model="model.spec.loadbalancer.vip_subnet_id"
></filter-select>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12 col-sm-8 col-md-6">
<div class="form-group">
<label class="control-label required" translate>Admin State Up</label>
@ -67,7 +73,6 @@
</div>
</div>
</div>
</div>
</div>

View File

@ -84,6 +84,7 @@
subnets: [],
members: [],
networks: {},
listenerProtocols: ['HTTP', 'TCP', 'TERMINATED_HTTPS', 'HTTPS'],
l7policyActions: ['REJECT', 'REDIRECT_TO_URL', 'REDIRECT_TO_POOL'],
l7ruleTypes: ['HOST_NAME', 'PATH', 'FILE_TYPE', 'HEADER', 'COOKIE'],
@ -264,11 +265,18 @@
return $q.all([
neutronAPI.getSubnets().then(onGetSubnets),
neutronAPI.getPorts().then(onGetPorts),
neutronAPI.getNetworks().then(onGetNetworks),
novaAPI.getServers().then(onGetServers),
keymanagerPromise.then(prepareCertificates, angular.noop)
]).then(initMemberAddresses);
}
function onGetNetworks(response) {
angular.forEach(response.data.items, function(value) {
model.networks[value.id] = value;
});
}
function initCreateListener(keymanagerPromise) {
model.context.submit = createListener;
return $q.all([
@ -330,7 +338,8 @@
model.context.submit = editLoadBalancer;
return $q.all([
lbaasv2API.getLoadBalancer(model.context.id).then(onGetLoadBalancer),
neutronAPI.getSubnets().then(onGetSubnets)
neutronAPI.getSubnets().then(onGetSubnets),
neutronAPI.getNetworks().then(onGetNetworks)
]).then(initSubnet);
}

View File

@ -17,7 +17,8 @@
'use strict';
describe('LBaaS v2 Workflow Model Service', function() {
var model, $q, scope, listenerResources, barbicanEnabled, certificatesError;
var model, $q, scope, listenerResources, barbicanEnabled,
certificatesError, mockNetworks;
var includeChildResources = true;
beforeEach(module('horizon.framework.util.i18n'));
@ -33,7 +34,7 @@
protocol: 'HTTP',
protocol_port: 80,
connection_limit: 999,
load_balancers: [ { id: '1234' } ],
load_balancers: [{id: '1234'}],
sni_container_refs: ['container2'],
insert_headers: {
'X-Forwarded-For': 'True',
@ -84,19 +85,29 @@
};
barbicanEnabled = true;
certificatesError = false;
mockNetworks = {
a1: {
name: 'network_1',
id: 'a1'
},
b2: {
name: 'network_2',
id: 'b2'
}
};
});
beforeEach(module(function($provide) {
$provide.value('horizon.app.core.openstack-service-api.lbaasv2', {
getLoadBalancers: function() {
var loadbalancers = [
{ id: '1234', name: 'Load Balancer 1' },
{ id: '5678', name: 'Load Balancer 2' },
{ id: '9012', name: 'myLoadBalancer3' }
{id: '1234', name: 'Load Balancer 1'},
{id: '5678', name: 'Load Balancer 2'},
{id: '9012', name: 'myLoadBalancer3'}
];
var deferred = $q.defer();
deferred.resolve({ data: { items: loadbalancers } });
deferred.resolve({data: {items: loadbalancers}});
return deferred.promise;
},
@ -111,31 +122,36 @@
};
var deferred = $q.defer();
deferred.resolve({ data: loadbalancer });
deferred.resolve({data: loadbalancer});
return deferred.promise;
},
getListeners: function() {
var listeners = [
{ id: '1234', name: 'Listener 1', protocol_port: 80 },
{ id: '5678', name: 'Listener 2', protocol_port: 81 },
{ id: '9012', name: 'myListener3', protocol_port: 82 }
{id: '1234', name: 'Listener 1', protocol_port: 80},
{id: '5678', name: 'Listener 2', protocol_port: 81},
{id: '9012', name: 'myListener3', protocol_port: 82}
];
var deferred = $q.defer();
deferred.resolve({ data: { items: listeners } });
deferred.resolve({data: {items: listeners}});
return deferred.promise;
},
getPools: function() {
var pools = [
{ id: '1234', name: 'Pool 1', listeners: [ '1234' ], protocol: 'HTTP' },
{ id: '5678', name: 'Pool 2', listeners: [], protocol: 'HTTP' },
{ id: '9012', name: 'Pool 3', listeners: [], protocol: 'HTTPS' }
{
id: '1234',
name: 'Pool 1',
listeners: ['1234'],
protocol: 'HTTP'
},
{id: '5678', name: 'Pool 2', listeners: [], protocol: 'HTTP'},
{id: '9012', name: 'Pool 3', listeners: [], protocol: 'HTTPS'}
];
var deferred = $q.defer();
deferred.resolve({ data: { items: pools } });
deferred.resolve({data: {items: pools}});
return deferred.promise;
},
@ -143,7 +159,7 @@
var deferred = $q.defer();
var listenerData;
listenerData = includeChildResources ? listenerResources : listenerResources.listener;
deferred.resolve({ data: listenerData });
deferred.resolve({data: listenerData});
return deferred.promise;
},
getL7Policy: function() {
@ -158,7 +174,7 @@
};
var deferred = $q.defer();
deferred.resolve({ data: l7policy });
deferred.resolve({data: l7policy});
return deferred.promise;
},
@ -173,7 +189,7 @@
};
var deferred = $q.defer();
deferred.resolve({ data: l7rule });
deferred.resolve({data: l7rule});
return deferred.promise;
},
@ -183,7 +199,7 @@
var deferred = $q.defer();
var poolData;
poolData = includeChildResources ? poolResources : poolResources.pool;
deferred.resolve({ data: poolData });
deferred.resolve({data: poolData});
return deferred.promise;
},
getMembers: function() {
@ -204,7 +220,7 @@
}];
var deferred = $q.defer();
deferred.resolve({ data: { items: members } });
deferred.resolve({data: {items: members}});
return deferred.promise;
},
@ -222,7 +238,7 @@
};
var deferred = $q.defer();
deferred.resolve({ data: monitor });
deferred.resolve({data: monitor});
return deferred.promise;
},
@ -276,12 +292,12 @@
}, {
container_ref: 'container2',
secret_refs: [{name: 'certificate', secret_ref: 'certificate1'},
{name: 'private_key', secret_ref: 'privatekey1'}]
{name: 'private_key', secret_ref: 'privatekey1'}]
},
{
container_ref: 'container3',
secret_refs: [{name: 'certificate', secret_ref: 'certificate2'},
{name: 'private_key', secret_ref: 'privatekey2'}]
{name: 'private_key', secret_ref: 'privatekey2'}]
}
];
@ -289,7 +305,7 @@
if (certificatesError) {
deferred.reject();
} else {
deferred.resolve({ data: { items: containers } });
deferred.resolve({data: {items: containers}});
}
return deferred.promise;
@ -300,18 +316,18 @@
name: 'foo',
expiration: '2016-03-26T21:10:45.417835',
secret_ref: 'certificate1'
},{
}, {
expiration: '2016-03-28T21:10:45.417835',
secret_ref: 'certificate2'
},{
}, {
secret_ref: 'privatekey1'
},{
}, {
secret_ref: 'privatekey2'
}
];
var deferred = $q.defer();
deferred.resolve({ data: { items: secrets } });
deferred.resolve({data: {items: secrets}});
return deferred.promise;
}
@ -319,37 +335,54 @@
$provide.value('horizon.app.core.openstack-service-api.neutron', {
getSubnets: function() {
var subnets = [ { id: 'subnet-1', name: 'subnet-1' },
{ id: 'subnet-2', name: 'subnet-2' } ];
var subnets = [{id: 'subnet-1', name: 'subnet-1'},
{id: 'subnet-2', name: 'subnet-2'}];
var deferred = $q.defer();
deferred.resolve({ data: { items: subnets } });
deferred.resolve({data: {items: subnets}});
return deferred.promise;
},
getPorts: function() {
var ports = [ { device_id: '1',
fixed_ips: [{ ip_address: '1.2.3.4', subnet_id: '1' },
{ ip_address: '2.3.4.5', subnet_id: '2' }] },
{ device_id: '2',
fixed_ips: [{ ip_address: '3.4.5.6', subnet_id: '1' },
{ ip_address: '4.5.6.7', subnet_id: '2' }] } ];
var ports = [{
device_id: '1',
fixed_ips: [{ip_address: '1.2.3.4', subnet_id: '1'},
{ip_address: '2.3.4.5', subnet_id: '2'}]
},
{
device_id: '2',
fixed_ips: [{ip_address: '3.4.5.6', subnet_id: '1'},
{ip_address: '4.5.6.7', subnet_id: '2'}]
}];
var deferred = $q.defer();
deferred.resolve({ data: { items: ports } });
deferred.resolve({data: {items: ports}});
return deferred.promise;
},
getNetworks: function() {
var networks = [{
name: 'network_1',
id: 'a1'
}, {
name: 'network_2',
id: 'b2'
}];
var deferred = $q.defer();
deferred.resolve({data: {items: networks}});
return deferred.promise;
}
});
$provide.value('horizon.app.core.openstack-service-api.nova', {
getServers: function() {
var servers = [ { id: '1', name: 'server-1', addresses: { foo: 'bar' } },
{ id: '2', name: 'server-2', addresses: { foo: 'bar' } },
{ id: '3', name: 'server-3' }];
var servers = [{id: '1', name: 'server-1', addresses: {foo: 'bar'}},
{id: '2', name: 'server-2', addresses: {foo: 'bar'}},
{id: '3', name: 'server-3'}];
var deferred = $q.defer();
deferred.resolve({ data: { items: servers } });
deferred.resolve({data: {items: servers}});
return deferred.promise;
}
@ -364,7 +397,7 @@
});
}));
beforeEach(inject(function ($injector) {
beforeEach(inject(function($injector) {
model = $injector.get(
'horizon.dashboard.project.lbaasv2.workflow.model'
);
@ -391,6 +424,10 @@
expect(model.subnets).toEqual([]);
});
it('has empty networks', function() {
expect(model.networks).toEqual({});
});
it('has empty members array', function() {
expect(model.members).toEqual([]);
});
@ -421,7 +458,7 @@
it('has array of monitor http_methods', function() {
expect(model.monitorMethods).toEqual(['GET', 'HEAD', 'POST', 'PUT', 'DELETE',
'TRACE', 'OPTIONS', 'PATCH', 'CONNECT']);
'TRACE', 'OPTIONS', 'PATCH', 'CONNECT']);
});
it('has an "initialize" function', function() {
@ -448,6 +485,7 @@
expect(model.initializing).toBe(false);
expect(model.initialized).toBe(true);
expect(model.subnets.length).toBe(2);
expect(model.networks).toEqual(mockNetworks);
expect(model.members.length).toBe(2);
expect(model.certificates.length).toBe(2);
expect(model.listenerPorts.length).toBe(0);
@ -703,6 +741,7 @@
expect(model.initializing).toBe(false);
expect(model.initialized).toBe(true);
expect(model.subnets.length).toBe(2);
expect(model.networks).toEqual(mockNetworks);
expect(model.members.length).toBe(0);
expect(model.certificates.length).toBe(0);
expect(model.listenerPorts.length).toBe(0);
@ -721,7 +760,10 @@
expect(model.spec.loadbalancer.name).toEqual('Load Balancer 1');
expect(model.spec.loadbalancer.description).toEqual('');
expect(model.spec.loadbalancer.vip_address).toEqual('1.2.3.4');
expect(model.spec.loadbalancer.vip_subnet_id).toEqual({ id: 'subnet-1', name: 'subnet-1' });
expect(model.spec.loadbalancer.vip_subnet_id).toEqual({
id: 'subnet-1',
name: 'subnet-1'
});
});
it('should not initialize listener model spec properties', function() {
@ -902,12 +944,18 @@
it('should initialize members and properties', function() {
expect(model.spec.members[0].id).toBe('1234');
expect(model.spec.members[0].address).toBe('1.2.3.4');
expect(model.spec.members[0].subnet_id).toEqual({ id: 'subnet-1', name: 'subnet-1' });
expect(model.spec.members[0].subnet_id).toEqual({
id: 'subnet-1',
name: 'subnet-1'
});
expect(model.spec.members[0].protocol_port).toBe(80);
expect(model.spec.members[0].weight).toBe(1);
expect(model.spec.members[1].id).toBe('5678');
expect(model.spec.members[1].address).toBe('5.6.7.8');
expect(model.spec.members[1].subnet_id).toEqual({ id: 'subnet-1', name: 'subnet-1' });
expect(model.spec.members[1].subnet_id).toEqual({
id: 'subnet-1',
name: 'subnet-1'
});
expect(model.spec.members[1].protocol_port).toBe(80);
expect(model.spec.members[1].weight).toBe(1);
});
@ -1033,12 +1081,18 @@
it('should initialize members and properties', function() {
expect(model.spec.members[0].id).toBe('1234');
expect(model.spec.members[0].address).toBe('1.2.3.4');
expect(model.spec.members[0].subnet_id).toEqual({ id: 'subnet-1', name: 'subnet-1' });
expect(model.spec.members[0].subnet_id).toEqual({
id: 'subnet-1',
name: 'subnet-1'
});
expect(model.spec.members[0].protocol_port).toBe(80);
expect(model.spec.members[0].weight).toBe(1);
expect(model.spec.members[1].id).toBe('5678');
expect(model.spec.members[1].address).toBe('5.6.7.8');
expect(model.spec.members[1].subnet_id).toEqual({ id: 'subnet-1', name: 'subnet-1' });
expect(model.spec.members[1].subnet_id).toEqual({
id: 'subnet-1',
name: 'subnet-1'
});
expect(model.spec.members[1].protocol_port).toBe(80);
expect(model.spec.members[1].weight).toBe(1);
});
@ -1117,12 +1171,18 @@
it('should initialize members and properties', function() {
expect(model.spec.members[0].id).toBe('1234');
expect(model.spec.members[0].address).toBe('1.2.3.4');
expect(model.spec.members[0].subnet_id).toEqual({ id: 'subnet-1', name: 'subnet-1' });
expect(model.spec.members[0].subnet_id).toEqual({
id: 'subnet-1',
name: 'subnet-1'
});
expect(model.spec.members[0].protocol_port).toBe(80);
expect(model.spec.members[0].weight).toBe(1);
expect(model.spec.members[1].id).toBe('5678');
expect(model.spec.members[1].address).toBe('5.6.7.8');
expect(model.spec.members[1].subnet_id).toEqual({ id: 'subnet-1', name: 'subnet-1' });
expect(model.spec.members[1].subnet_id).toEqual({
id: 'subnet-1',
name: 'subnet-1'
});
expect(model.spec.members[1].protocol_port).toBe(80);
expect(model.spec.members[1].weight).toBe(1);
});
@ -1294,7 +1354,7 @@
describe('Initialization failure', function() {
beforeEach(inject(function ($injector) {
beforeEach(inject(function($injector) {
var neutronAPI = $injector.get('horizon.app.core.openstack-service-api.neutron');
neutronAPI.getSubnets = function() {
var deferred = $q.defer();
@ -1434,9 +1494,9 @@
model.spec.pool.lb_algorithm = 'LEAST_CONNECTIONS';
model.spec.pool.session_persistence.type = 'SOURCE_IP';
model.spec.members = [{
address: { ip: '1.2.3.4', subnet: '1' },
addresses: [{ ip: '1.2.3.4', subnet: '1' },
{ ip: '2.3.4.5', subnet: '2' }],
address: {ip: '1.2.3.4', subnet: '1'},
addresses: [{ip: '1.2.3.4', subnet: '1'},
{ip: '2.3.4.5', subnet: '2'}],
id: '1',
name: 'foo',
protocol_port: 80,
@ -1456,7 +1516,7 @@
}, {
id: 'external-member-2',
address: '3.4.5.6',
subnet_id: { id: '1' },
subnet_id: {id: '1'},
protocol_port: 80,
weight: 1
}];
@ -1690,9 +1750,9 @@
model.spec.pool.description = 'pool description';
model.spec.pool.lb_algorithm = 'LEAST_CONNECTIONS';
model.spec.members = [{
address: { ip: '1.2.3.4', subnet: '1' },
addresses: [{ ip: '1.2.3.4', subnet: '1' },
{ ip: '2.3.4.5', subnet: '2' }],
address: {ip: '1.2.3.4', subnet: '1'},
addresses: [{ip: '1.2.3.4', subnet: '1'},
{ip: '2.3.4.5', subnet: '2'}],
id: '1',
name: 'foo',
protocol_port: 80,
@ -1712,7 +1772,7 @@
}, {
id: 'external-member-2',
address: '3.4.5.6',
subnet_id: { id: '1' },
subnet_id: {id: '1'},
protocol_port: 80,
weight: 1
}];
@ -1945,9 +2005,9 @@
model.spec.pool.description = 'pool description';
model.spec.pool.lb_algorithm = 'LEAST_CONNECTIONS';
model.spec.members = [{
address: { ip: '1.2.3.4', subnet: '1' },
addresses: [{ ip: '1.2.3.4', subnet: '1' },
{ ip: '2.3.4.5', subnet: '2' }],
address: {ip: '1.2.3.4', subnet: '1'},
addresses: [{ip: '1.2.3.4', subnet: '1'},
{ip: '2.3.4.5', subnet: '2'}],
id: '1',
name: 'foo',
protocol_port: 80,
@ -1967,7 +2027,7 @@
}, {
id: 'external-member-2',
address: '3.4.5.6',
subnet_id: { id: '1' },
subnet_id: {id: '1'},
protocol_port: 80,
weight: 1
}];

View File

@ -0,0 +1,11 @@
---
features:
- |
Adds a new UI component which works as a standard select control
alternative. Options in the component are presented in a table which may be
filtered using the select input field. Filtering is done across all table
fields.
upgrade:
- |
The new component replaces the standard select for subnet selection in
the Load Balancer creation modal wizard.