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:
parent
cdf26e99c8
commit
35f6f90d0c
3
.gitignore
vendored
3
.gitignore
vendored
@ -63,5 +63,8 @@ ChangeLog
|
||||
.ropeproject/
|
||||
.DS_Store
|
||||
|
||||
# IntelliJ editors
|
||||
.idea
|
||||
|
||||
# Conf
|
||||
octavia_dashboard/conf
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
@ -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';
|
||||
}
|
||||
})();
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
@ -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>
|
@ -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);
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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'));
|
||||
@ -84,6 +85,16 @@
|
||||
};
|
||||
barbicanEnabled = true;
|
||||
certificatesError = false;
|
||||
mockNetworks = {
|
||||
a1: {
|
||||
name: 'network_1',
|
||||
id: 'a1'
|
||||
},
|
||||
b2: {
|
||||
name: 'network_2',
|
||||
id: 'b2'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(module(function($provide) {
|
||||
@ -129,7 +140,12 @@
|
||||
},
|
||||
getPools: function() {
|
||||
var pools = [
|
||||
{ id: '1234', name: 'Pool 1', listeners: [ '1234' ], protocol: 'HTTP' },
|
||||
{
|
||||
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'}
|
||||
];
|
||||
@ -328,16 +344,33 @@
|
||||
return deferred.promise;
|
||||
},
|
||||
getPorts: function() {
|
||||
var ports = [ { device_id: '1',
|
||||
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',
|
||||
{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' }] } ];
|
||||
{ip_address: '4.5.6.7', subnet_id: '2'}]
|
||||
}];
|
||||
|
||||
var deferred = $q.defer();
|
||||
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;
|
||||
}
|
||||
});
|
||||
@ -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([]);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
|
11
releasenotes/notes/filter-select-65160dcbe699a96d.yaml
Normal file
11
releasenotes/notes/filter-select-65160dcbe699a96d.yaml
Normal 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.
|
Loading…
Reference in New Issue
Block a user