diff --git a/.gitignore b/.gitignore index 48cb55a7..6643fab2 100644 --- a/.gitignore +++ b/.gitignore @@ -63,5 +63,8 @@ ChangeLog .ropeproject/ .DS_Store +# IntelliJ editors +.idea + # Conf octavia_dashboard/conf diff --git a/octavia_dashboard/static/dashboard/project/lbaasv2/lbaasv2.scss b/octavia_dashboard/static/dashboard/project/lbaasv2/lbaasv2.scss index 408e3ab2..e4055963 100644 --- a/octavia_dashboard/static/dashboard/project/lbaasv2/lbaasv2.scss +++ b/octavia_dashboard/static/dashboard/project/lbaasv2/lbaasv2.scss @@ -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; + } + } + } + +} + diff --git a/octavia_dashboard/static/dashboard/project/lbaasv2/widgets/filterselect/filter-select.component.js b/octavia_dashboard/static/dashboard/project/lbaasv2/widgets/filterselect/filter-select.component.js new file mode 100644 index 00000000..b03f1e6d --- /dev/null +++ b/octavia_dashboard/static/dashboard/project/lbaasv2/widgets/filterselect/filter-select.component.js @@ -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']; + * }; + * + * ``` + * + * + * ``` + * + * 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'; + } +})(); diff --git a/octavia_dashboard/static/dashboard/project/lbaasv2/widgets/filterselect/filter-select.component.spec.js b/octavia_dashboard/static/dashboard/project/lbaasv2/widgets/filterselect/filter-select.component.spec.js new file mode 100644 index 00000000..d5872573 --- /dev/null +++ b/octavia_dashboard/static/dashboard/project/lbaasv2/widgets/filterselect/filter-select.component.spec.js @@ -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 = ''; + + var parentElement = angular.element('
'); + otherElement = angular.element('
'); + 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('') + }); + 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 + }); + }); + }); + }); +})(); diff --git a/octavia_dashboard/static/dashboard/project/lbaasv2/widgets/filterselect/filter-select.html b/octavia_dashboard/static/dashboard/project/lbaasv2/widgets/filterselect/filter-select.html new file mode 100644 index 00000000..e1d31ac7 --- /dev/null +++ b/octavia_dashboard/static/dashboard/project/lbaasv2/widgets/filterselect/filter-select.html @@ -0,0 +1,43 @@ +
+
+
+
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + +
+ {$ column.label $} +
+ {$ column[0] $}{$ column[1] $}{$ column[2] $} +
+ No matching options +
+
+
\ No newline at end of file diff --git a/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/loadbalancer/loadbalancer.controller.js b/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/loadbalancer/loadbalancer.controller.js index 5b74d3dd..b56cf964 100644 --- a/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/loadbalancer/loadbalancer.controller.js +++ b/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/loadbalancer/loadbalancer.controller.js @@ -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); + }; } })(); diff --git a/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/loadbalancer/loadbalancer.controller.spec.js b/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/loadbalancer/loadbalancer.controller.spec.js index 6a7ba773..0daa74a9 100644 --- a/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/loadbalancer/loadbalancer.controller.spec.js +++ b/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/loadbalancer/loadbalancer.controller.spec.js @@ -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); + }); }); }); })(); diff --git a/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/loadbalancer/loadbalancer.html b/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/loadbalancer/loadbalancer.html index ad7377e8..4bc0b005 100644 --- a/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/loadbalancer/loadbalancer.html +++ b/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/loadbalancer/loadbalancer.html @@ -11,18 +11,6 @@ -
-
- - -
-
- - - -
-
@@ -35,25 +23,43 @@
- -
-
- - -
-
-
+
+
+ + +
+
+
+
+
+
+ + + +
+
+
+ +
@@ -67,7 +73,6 @@
- diff --git a/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/model.service.js b/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/model.service.js index 987a353e..0f7a4e07 100644 --- a/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/model.service.js +++ b/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/model.service.js @@ -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); } diff --git a/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/model.service.spec.js b/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/model.service.spec.js index c4a6e76d..d07d5b86 100644 --- a/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/model.service.spec.js +++ b/octavia_dashboard/static/dashboard/project/lbaasv2/workflow/model.service.spec.js @@ -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 }]; diff --git a/releasenotes/notes/filter-select-65160dcbe699a96d.yaml b/releasenotes/notes/filter-select-65160dcbe699a96d.yaml new file mode 100644 index 00000000..c86d3cda --- /dev/null +++ b/releasenotes/notes/filter-select-65160dcbe699a96d.yaml @@ -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. \ No newline at end of file