Trunks panel: eliminate spinner at create/edit

Change how the UI reacts to neutron responding to API requests.

Without this change when the user clicks create or edit:
* we display a spinner
* wait for the neutron to respond
* then we open the modal.

With this change:
* we open the modal immediately and
* send the request(s) to neutron asynchronously.
* We display a 'please wait' message in place of the relevant (but not
  all) input forms (or transfer tables) of the workflow steps.
* When neutron responds to each request we replace the 'please wait'
  message with the pre-filled input forms.

The latter is better experience for the user because he or she can
progress with parts of the workflow until the rest is loaded.

Partially-Implements: blueprint neutron-trunk-ui
Change-Id: I9ac8f75a390424ad05cf51fa679ef9803124179c
This commit is contained in:
Bence Romsics
2017-11-27 16:05:50 +01:00
parent 0a51ce628d
commit 705c52bf1f
13 changed files with 780 additions and 727 deletions

View File

@@ -30,7 +30,6 @@
'horizon.app.core.trunks.actions.ports-extra.service', 'horizon.app.core.trunks.actions.ports-extra.service',
'horizon.app.core.trunks.resourceType', 'horizon.app.core.trunks.resourceType',
'horizon.framework.util.actions.action-result.service', 'horizon.framework.util.actions.action-result.service',
'horizon.framework.widgets.modal-wait-spinner.service',
// Using horizon.framework.widgets.form.ModalFormService and // Using horizon.framework.widgets.form.ModalFormService and
// angular-schema-form would have made many things easier, but it wasn't // angular-schema-form would have made many things easier, but it wasn't
// really an option because it does not have a transfer-table widget. // really an option because it does not have a transfer-table widget.
@@ -52,7 +51,6 @@
portsExtra, portsExtra,
resourceType, resourceType,
actionResultService, actionResultService,
spinnerService,
wizardModalService, wizardModalService,
toast toast
) { ) {
@@ -74,65 +72,36 @@
} }
function perform() { function perform() {
// NOTE(bence romsics): Suboptimal UX. We delay opening the modal until // NOTE(bence romsics): The parent and subport selector steps are shared
// neutron objects are loaded. But ideally the steps independent of // by the create and edit workflows, therefore we have to initialize the
// already existing neutron objects (ie. the trunk details step) could // trunk adequately in both cases. That is an empty trunk for create and
// already work while loading neutron stuff in the background. // the trunk to be updated for edit.
spinnerService.showModalSpinner(gettext('Please Wait')); var trunk = {
admin_state_up: true,
return $q.all({ description: '',
// TODO(bence romsics): Query filters of port and network listings name: '',
// should be aligned. While here it looks like we query all port_id: undefined,
// possible ports and all possible networks this is not really the sub_ports: []
// case. A few calls down in openstack_dashboard/api/neutron.py };
// we have a filterless port listing, but networks are listed return wizardModalService.modal({
// by network_list_for_tenant() which includes some hardcoded workflow: createWorkflow,
// tenant-specific filtering. Therefore here we may get back some submit: submit,
// ports whose networks we don't have. This is only a problem for data: {
// admin and even then it means just missing network and subnet initTrunk: trunk,
// metadata on some ports. But anyway when we want this panel to getTrunk: $q.when(trunk),
// work for admin too, we should fix this. getPortsWithNets: $q.all({
getNetworks: neutron.getNetworks(), getNetworks: neutron.getNetworks(),
getPorts: userSession.get().then(function(session) { getPorts: userSession.get().then(function(session) {
return neutron.getPorts({project_id: session.project_id}); return neutron.getPorts({project_id: session.project_id});
}) })
}).then(function(responses) { }).then(function(responses) {
var networks = responses.getNetworks.data.items; var networks = responses.getNetworks.data.items;
var ports = responses.getPorts.data.items; var ports = responses.getPorts.data.items;
return { return portsExtra.addNetworkAndSubnetInfo(
parentPortCandidates: portsExtra.addNetworkAndSubnetInfo( ports, networks).sort(portsExtra.cmpPortsByNameAndId);
ports.filter(portsExtra.isParentPortCandidate), })
networks), }
subportCandidates: portsExtra.addNetworkAndSubnetInfo( }).result;
ports.filter(portsExtra.isSubportCandidate),
networks)
};
}).then(openModal);
function openModal(params) {
spinnerService.hideModalSpinner();
return wizardModalService.modal({
workflow: createWorkflow,
submit: submit,
data: {
initTrunk: {
admin_state_up: true,
description: '',
name: '',
port_id: undefined,
sub_ports: []
},
ports: {
parentPortCandidates: params.parentPortCandidates.sort(
portsExtra.cmpPortsByNameAndId),
subportCandidates: params.subportCandidates.sort(
portsExtra.cmpPortsByNameAndId),
subportsOfInitTrunk: []
}
}
}).result;
}
} }
function submit(stepModels) { function submit(stepModels) {

View File

@@ -104,22 +104,17 @@
it('open the modal with the correct parameters', function() { it('open the modal with the correct parameters', function() {
spyOn(wizardModalService, 'modal').and.callThrough(); spyOn(wizardModalService, 'modal').and.callThrough();
spyOn(modalWaitSpinnerService, 'showModalSpinner');
spyOn(modalWaitSpinnerService, 'hideModalSpinner');
service.perform(); service.perform();
expect(modalWaitSpinnerService.showModalSpinner).toHaveBeenCalledWith('Please Wait');
$timeout.flush();
expect(wizardModalService.modal).toHaveBeenCalled(); expect(wizardModalService.modal).toHaveBeenCalled();
expect(modalWaitSpinnerService.hideModalSpinner).toHaveBeenCalled();
var modalArgs = wizardModalService.modal.calls.argsFor(0)[0]; var modalArgs = wizardModalService.modal.calls.argsFor(0)[0];
expect(modalArgs.scope).toBeUndefined(); expect(modalArgs.scope).toBeUndefined();
expect(modalArgs.workflow).toBeDefined(); expect(modalArgs.workflow).toBeDefined();
expect(modalArgs.submit).toBeDefined(); expect(modalArgs.submit).toBeDefined();
expect(modalArgs.data.initTrunk).toBeDefined(); expect(modalArgs.data.initTrunk).toBeDefined();
expect(modalArgs.data.ports).toBeDefined(); expect(modalArgs.data.getTrunk).toBeDefined();
expect(modalArgs.data.getPortsWithNets).toBeDefined();
}); });
it('should submit create trunk request to neutron', function() { it('should submit create trunk request to neutron', function() {

View File

@@ -23,6 +23,7 @@
editService.$inject = [ editService.$inject = [
'$q', '$q',
'$location',
'horizon.app.core.openstack-service-api.neutron', 'horizon.app.core.openstack-service-api.neutron',
'horizon.app.core.openstack-service-api.policy', 'horizon.app.core.openstack-service-api.policy',
'horizon.app.core.openstack-service-api.userSession', 'horizon.app.core.openstack-service-api.userSession',
@@ -30,7 +31,6 @@
'horizon.app.core.trunks.actions.ports-extra.service', 'horizon.app.core.trunks.actions.ports-extra.service',
'horizon.app.core.trunks.resourceType', 'horizon.app.core.trunks.resourceType',
'horizon.framework.util.actions.action-result.service', 'horizon.framework.util.actions.action-result.service',
'horizon.framework.widgets.modal-wait-spinner.service',
'horizon.framework.widgets.modal.wizard-modal.service', 'horizon.framework.widgets.modal.wizard-modal.service',
'horizon.framework.widgets.toast.service' 'horizon.framework.widgets.toast.service'
]; ];
@@ -42,6 +42,7 @@
*/ */
function editService( function editService(
$q, $q,
$location,
neutron, neutron,
policy, policy,
userSession, userSession,
@@ -49,7 +50,6 @@
portsExtra, portsExtra,
resourceType, resourceType,
actionResultService, actionResultService,
spinnerService,
wizardModalService, wizardModalService,
toast toast
) { ) {
@@ -71,48 +71,37 @@
} }
function perform(selected) { function perform(selected) {
// See also at perform() in create action. var params = {};
spinnerService.showModalSpinner(gettext('Please Wait'));
return $q.all({ if ($location.url().indexOf('admin') === -1) {
getNetworks: neutron.getNetworks(), params = {project_id: userSession.project_id};
getPorts: userSession.get().then(function(session) {
return neutron.getPorts({project_id: session.project_id});
}),
getTrunk: neutron.getTrunk(selected.id)
}).then(function(responses) {
var networks = responses.getNetworks.data.items;
var ports = responses.getPorts.data.items;
var trunk = responses.getTrunk.data;
return {
subportCandidates: portsExtra.addNetworkAndSubnetInfo(
ports.filter(portsExtra.isSubportCandidate),
networks),
subportsOfInitTrunk: portsExtra.addNetworkAndSubnetInfo(
ports.filter(portsExtra.isSubportOfTrunk.bind(null, selected.id)),
networks),
trunk: trunk
};
}).then(openModal);
function openModal(params) {
spinnerService.hideModalSpinner();
return wizardModalService.modal({
workflow: editWorkflow,
submit: submit,
data: {
initTrunk: params.trunk,
ports: {
parentPortCandidates: [],
subportCandidates: params.subportCandidates.sort(
portsExtra.cmpPortsByNameAndId),
subportsOfInitTrunk: params.subportsOfInitTrunk.sort(
portsExtra.cmpSubportsBySegmentationTypeAndId)
}
}
}).result;
} }
return wizardModalService.modal({
workflow: editWorkflow,
submit: submit,
data: {
// The step controllers can and will freshly query the trunk
// by using the getTrunk promise below. For all updateable
// attributes you should use that. But to make our lives a bit
// easier we also pass synchronously (and redundantly) the trunk
// we queried earlier. Remember to only use those attributes
// of it that are not allowed to be updated.
initTrunk: selected,
getTrunk: neutron.getTrunk(selected.id).then(function(response) {
return response.data;
}),
getPortsWithNets: $q.all({
getNetworks: neutron.getNetworks(params),
getPorts: neutron.getPorts(params)
}).then(function(responses) {
var networks = responses.getNetworks.data.items;
var ports = responses.getPorts.data.items;
return portsExtra.addNetworkAndSubnetInfo(
ports, networks).sort(portsExtra.cmpPortsByNameAndId);
})
}
}).result;
} }
function submit(stepModels) { function submit(stepModels) {

View File

@@ -109,22 +109,17 @@
it('open the modal with the correct parameters', function() { it('open the modal with the correct parameters', function() {
spyOn(wizardModalService, 'modal').and.callThrough(); spyOn(wizardModalService, 'modal').and.callThrough();
spyOn(modalWaitSpinnerService, 'showModalSpinner');
spyOn(modalWaitSpinnerService, 'hideModalSpinner');
service.perform({id: 1}); service.perform({id: 1});
expect(modalWaitSpinnerService.showModalSpinner).toHaveBeenCalledWith('Please Wait');
$timeout.flush();
expect(wizardModalService.modal).toHaveBeenCalled(); expect(wizardModalService.modal).toHaveBeenCalled();
expect(modalWaitSpinnerService.hideModalSpinner).toHaveBeenCalled();
var modalArgs = wizardModalService.modal.calls.argsFor(0)[0]; var modalArgs = wizardModalService.modal.calls.argsFor(0)[0];
expect(modalArgs.scope).toBeUndefined(); expect(modalArgs.scope).toBeUndefined();
expect(modalArgs.workflow).toBeDefined(); expect(modalArgs.workflow).toBeDefined();
expect(modalArgs.submit).toBeDefined(); expect(modalArgs.submit).toBeDefined();
expect(modalArgs.data.initTrunk).toBeDefined(); expect(modalArgs.data.initTrunk).toBeDefined();
expect(modalArgs.data.ports).toBeDefined(); expect(modalArgs.data.getTrunk).toBeDefined();
expect(modalArgs.data.getPortsWithNets).toBeDefined();
}); });
it('should submit edit trunk request to neutron', function() { it('should submit edit trunk request to neutron', function() {

View File

@@ -43,46 +43,50 @@
{ label: gettext('Disabled'), value: false } { label: gettext('Disabled'), value: false }
]; ];
ctrl.trunk = { $scope.getTrunk.then(function(trunk) {
admin_state_up: $scope.initTrunk.admin_state_up, ctrl.trunk = {
description: $scope.initTrunk.description, admin_state_up: trunk.admin_state_up,
name: $scope.initTrunk.name description: trunk.description,
}; name: trunk.name
};
// NOTE(bence romsics): The step controllers are naturally stateful, // NOTE(bence romsics): The step controllers are naturally stateful,
// but the actions should be stateless. However we have to // but the actions should be stateless. However we have to
// get back the captured user input from the step controller to the // get back the captured user input from the step controller to the
// action, because the action makes the neutron call. WizardController // action, because the action makes the neutron call. WizardController
// helps us and passes $scope.stepModels to the actions' submit(). // helps us and passes $scope.stepModels to the actions' submit().
// Also note that $scope.stepModels is shared between all workflow // Also note that $scope.stepModels is shared between all workflow
// steps. // steps.
// //
// We roughly follow the example discussed and presented here: // We roughly follow the example discussed and presented here:
// http://lists.openstack.org/pipermail/openstack-dev/2016-July/099368.html // http://lists.openstack.org/pipermail/openstack-dev/2016-July/099368.html
// https://review.openstack.org/345145 // https://review.openstack.org/345145
// //
// Though we deviate a bit in the use of stepModels. The example // Though we deviate a bit in the use of stepModels. The example
// has one model object per step, named after the workflow step's // has one model object per step, named after the workflow step's
// form. Instead we treat stepModels as a generic state variable. See // form. Instead we treat stepModels as a generic state variable. See
// the details below. // the details below.
// //
// The trunkSlices closures return a slice of the trunk model which // The trunkSlices closures return a slice of the trunk model which
// can be then merged by the action to get the whole trunk model. By // can be then merged by the action to get the whole trunk model. By
// using closures we can spare the use of watchers and the constant // using closures we can spare the use of watchers and the constant
// recomputation of the trunk slices even in the more complicated // recomputation of the trunk slices even in the more complicated
// other steps. // other steps.
$scope.stepModels.trunkSlices = $scope.stepModels.trunkSlices || {}; $scope.stepModels.trunkSlices = $scope.stepModels.trunkSlices || {};
$scope.stepModels.trunkSlices.getDetails = function() { $scope.stepModels.trunkSlices.getDetails = function() {
return ctrl.trunk; return ctrl.trunk;
}; };
// In order to keep the update action stateless, we pass the old // In order to keep the update action stateless, we pass the old
// state of the trunk down to the step controllers, then back up // state of the trunk down to the step controllers, then back up
// to the update action's submit(). An alternative would be to // to the update action's submit(). An alternative would be to
// eliminate the need for the old state of the trunk at update, // eliminate the need for the old state of the trunk at update,
// at the price of moving the trunk diffing logic from python to // at the price of moving the trunk diffing logic from python to
// javascript (ie. the subports step controller). // javascript (ie. the subports step controller).
$scope.stepModels.initTrunk = $scope.initTrunk; $scope.stepModels.initTrunk = $scope.initTrunk;
ctrl.trunkLoaded = true;
});
} }

View File

@@ -21,17 +21,20 @@
beforeEach(module('horizon.app.core.trunks')); beforeEach(module('horizon.app.core.trunks'));
describe('TrunkDetailsController', function() { describe('TrunkDetailsController', function() {
var scope, ctrl; var $q, $timeout, scope, ctrl;
//beforeEach(module('horizon.app.core.trunks.actions')); beforeEach(inject(function(_$q_, _$timeout_, $rootScope, $controller) {
beforeEach(inject(function($rootScope, $injector, $controller) { $q = _$q_;
$timeout = _$timeout_;
scope = $rootScope.$new(); scope = $rootScope.$new();
scope.stepModels = {}; scope.stepModels = {};
scope.initTrunk = { var trunk = {
admin_state_up: true, admin_state_up: true,
description: '', description: '',
name: 'trunk1' name: 'trunk1'
}; };
scope.initTrunk = trunk;
scope.getTrunk = $q.when(trunk);
ctrl = $controller('TrunkDetailsController', { ctrl = $controller('TrunkDetailsController', {
$scope: scope $scope: scope
@@ -45,17 +48,23 @@
}); });
it('has trunk property', function() { it('has trunk property', function() {
expect(ctrl.trunk).toBeDefined(); scope.getTrunk.then(function() {
expect(ctrl.trunk.admin_state_up).toBeDefined(); expect(ctrl.trunk).toBeDefined();
expect(ctrl.trunk.admin_state_up).toEqual(true); expect(ctrl.trunk.admin_state_up).toBeDefined();
expect(ctrl.trunk.description).toBeDefined(); expect(ctrl.trunk.admin_state_up).toEqual(true);
expect(ctrl.trunk.name).toBeDefined(); expect(ctrl.trunk.description).toBeDefined();
expect(ctrl.trunk.name).toBeDefined();
});
$timeout.flush();
}); });
it('should return with trunk', function() { it('should return with trunk', function() {
var trunk = scope.stepModels.trunkSlices.getDetails(); scope.getTrunk.then(function() {
expect(trunk.name).toEqual('trunk1'); var trunk = scope.stepModels.trunkSlices.getDetails();
expect(trunk.admin_state_up).toBe(true); expect(trunk.name).toEqual('trunk1');
expect(trunk.admin_state_up).toBe(true);
});
$timeout.flush();
}); });
}); });

View File

@@ -7,44 +7,51 @@
</p> </p>
<div class="content"> <div class="content">
<div ng-if="!ctrl.trunkLoaded">
<span translate class="subtitle text-info">
Loading trunk... Please Wait
</span>
</div>
<div class="selected-source"> <div ng-if="ctrl.trunkLoaded">
<div class="row form-group"> <div class="selected-source">
<div class="col-xs-8 col-sm-8"> <div class="row form-group">
<div class="form-group"> <div class="col-xs-8 col-sm-8">
<label class="control-label" for="trunkForm-name"> <div class="form-group">
<translate>Name</translate> <label class="control-label" for="trunkForm-name">
</label> <translate>Name</translate>
<input id="trunkForm-name" name="name" </label>
type="text" class="form-control" <input id="trunkForm-name" name="name"
ng-model="ctrl.trunk.name"> type="text" class="form-control"
ng-model="ctrl.trunk.name">
</div>
</div> </div>
</div> <div class="col-xs-4 col-sm-4">
<div class="col-xs-4 col-sm-4"> <div class="form-group">
<div class="form-group"> <label class="control-label">
<label class="control-label"> <translate>Admin State</translate>
<translate>Admin State</translate> </label>
</label> <div class="form-field">
<div class="form-field"> <div class="btn-group">
<div class="btn-group"> <label class="btn btn-default"
<label class="btn btn-default" ng-repeat="option in ctrl.trunkAdminStateOptions"
ng-repeat="option in ctrl.trunkAdminStateOptions" ng-model="ctrl.trunk.admin_state_up"
ng-model="ctrl.trunk.admin_state_up" uib-btn-radio="option.value">{$ ::option.label $}</label>
uib-btn-radio="option.value">{$ ::option.label $}</label> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> <div class="row form-group">
<div class="row form-group"> <div class="col-xs-12 col-sm-12">
<div class="col-xs-12 col-sm-12"> <div class="form-group">
<div class="form-group"> <label class="control-label" for="trunkForm-description">
<label class="control-label" for="trunkForm-description"> <translate>Description</translate>
<translate>Description</translate> </label>
</label> <input id="trunkForm-description" name="description"
<input id="trunkForm-description" name="description" type="text" class="form-control"
type="text" class="form-control" ng-model="ctrl.trunk.description">
ng-model="ctrl.trunk.description"> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -32,6 +32,7 @@
TrunkParentPortController.$inject = [ TrunkParentPortController.$inject = [
'$scope', '$scope',
'horizon.app.core.trunks.actions.ports-extra.service',
'horizon.app.core.trunks.portConstants', 'horizon.app.core.trunks.portConstants',
'horizon.framework.widgets.action-list.button-tooltip.row-warning.service', 'horizon.framework.widgets.action-list.button-tooltip.row-warning.service',
'horizon.framework.widgets.transfer-table.events' 'horizon.framework.widgets.transfer-table.events'
@@ -39,11 +40,13 @@
function TrunkParentPortController( function TrunkParentPortController(
$scope, $scope,
portsExtra,
portConstants, portConstants,
tooltipService, tooltipService,
ttevents ttevents
) { ) {
var ctrl = this; var ctrl = this;
var parentPortCandidates;
ctrl.portStatuses = portConstants.statuses; ctrl.portStatuses = portConstants.statuses;
ctrl.portAdminStates = portConstants.adminStates; ctrl.portAdminStates = portConstants.adminStates;
@@ -63,61 +66,72 @@
maxAllocation: 1 maxAllocation: 1
}; };
ctrl.parentTables = { $scope.getPortsWithNets.then(function(portsWithNets) {
available: $scope.ports.parentPortCandidates, parentPortCandidates = portsWithNets.filter(
allocated: [], portsExtra.isParentPortCandidate);
displayedAvailable: [],
displayedAllocated: []
};
// See also in the details step controller. ctrl.parentTables = {
$scope.stepModels.trunkSlices = $scope.stepModels.trunkSlices || {}; available: parentPortCandidates,
$scope.stepModels.trunkSlices.getParentPort = function() { allocated: [],
var trunk = {port_id: $scope.initTrunk.port_id}; displayedAvailable: [],
displayedAllocated: []
};
if (ctrl.parentTables.allocated.length in [0, 1]) { // See also in the details step controller.
trunk.port_id = ctrl.parentTables.allocated[0].id; $scope.stepModels.trunkSlices = $scope.stepModels.trunkSlices || {};
} else { $scope.stepModels.trunkSlices.getParentPort = function() {
// maxAllocation is 1, so this should never happen. var trunk = {port_id: $scope.initTrunk.port_id};
throw new Error('Allocating multiple parent ports is meaningless.');
if (ctrl.parentTables.allocated.length in [0, 1]) {
trunk.port_id = ctrl.parentTables.allocated[0].id;
} else {
// maxAllocation is 1, so this should never happen.
throw new Error('Allocating multiple parent ports is meaningless.');
}
return trunk;
};
// We expose the allocated table directly to the subports step
// controller, so it can set watchers on it and react accordingly...
$scope.stepModels.allocated = $scope.stepModels.allocated || {};
$scope.stepModels.allocated.parentPort = ctrl.parentTables.allocated;
// ...and vice versa.
var deregisterAllocatedWatcher = $scope.$watchCollection(
'stepModels.allocated.subports', hideAllocated);
$scope.$on('$destroy', function() {
deregisterAllocatedWatcher();
});
function hideAllocated(allocatedList) {
if (!ctrl.portsLoaded || !allocatedList) {
return;
}
var allocatedDict = {};
var availableList;
allocatedList.forEach(function(port) {
allocatedDict[port.id] = true;
});
availableList = parentPortCandidates.filter(
function(port) {
return !(port.id in allocatedDict);
}
);
ctrl.parentTables.available = availableList;
// Notify transfertable.
$scope.$broadcast(
ttevents.TABLES_CHANGED,
{data: {available: availableList}}
);
} }
return trunk; ctrl.portsLoaded = true;
};
// We expose the allocated table directly to the subports step
// controller, so it can set watchers on it and react accordingly...
$scope.stepModels.allocated = $scope.stepModels.allocated || {};
$scope.stepModels.allocated.parentPort = ctrl.parentTables.allocated;
// ...and vice versa.
var deregisterAllocatedWatcher = $scope.$watchCollection(
'stepModels.allocated.subports', hideAllocated);
$scope.$on('$destroy', function() {
deregisterAllocatedWatcher();
}); });
function hideAllocated(allocatedList) {
var allocatedDict = {};
var availableList;
allocatedList.forEach(function(port) {
allocatedDict[port.id] = true;
});
availableList = $scope.ports.parentPortCandidates.filter(
function(port) {
return !(port.id in allocatedDict);
}
);
ctrl.parentTables.available = availableList;
// Notify transfertable.
$scope.$broadcast(
ttevents.TABLES_CHANGED,
{data: {available: availableList}}
);
}
} }
})(); })();

View File

@@ -23,22 +23,23 @@
beforeEach(module('horizon.app.core.trunks')); beforeEach(module('horizon.app.core.trunks'));
describe('TrunkParentPortController', function() { describe('TrunkParentPortController', function() {
var scope, ctrl, ttevents; var $q, $timeout, $scope, ctrl;
beforeEach(inject(function($rootScope, $controller, $injector) { beforeEach(inject(function(_$q_, _$timeout_, $rootScope, $controller) {
scope = $rootScope.$new(); $q = _$q_;
scope.ports = { $timeout = _$timeout_;
parentPortCandidates: [{id: 1}, {id: 2}] $scope = $rootScope.$new();
}; $scope.getPortsWithNets = $q.when([
scope.stepModels = {}; {id: 1, admin_state_up: true, device_owner: ''},
scope.initTrunk = { {id: 2, admin_state_up: true, device_owner: ''}
]);
$scope.stepModels = {};
$scope.initTrunk = {
port_id: 1 port_id: 1
}; };
ttevents = $injector.get('horizon.framework.widgets.transfer-table.events');
ctrl = $controller('TrunkParentPortController', { ctrl = $controller('TrunkParentPortController', {
$scope: scope $scope: $scope
}); });
})); }));
@@ -78,38 +79,54 @@
}); });
it('uses scope to set table data', function() { it('uses scope to set table data', function() {
expect(ctrl.parentTables).toBeDefined(); $scope.getPortsWithNets.then(function() {
expect(ctrl.parentTables.available).toEqual( expect(ctrl.parentTables).toBeDefined();
[{id: 1}, {id: 2}]); expect(ctrl.parentTables.available).toEqual([
expect(ctrl.parentTables.allocated).toEqual([]); {id: 1, admin_state_up: true, device_owner: ''},
expect(ctrl.parentTables.displayedAllocated).toEqual([]); {id: 2, admin_state_up: true, device_owner: ''}
expect(ctrl.parentTables.displayedAvailable).toEqual([]); ]);
expect(ctrl.parentTables.allocated).toEqual([]);
expect(ctrl.parentTables.displayedAllocated).toEqual([]);
expect(ctrl.parentTables.displayedAvailable).toEqual([]);
});
$timeout.flush();
}); });
it('should return with parent port', function() { it('should return with parent port', function() {
ctrl.parentTables.allocated = [{id: 3}]; $scope.getPortsWithNets.then(function() {
var trunk = scope.stepModels.trunkSlices.getParentPort(); ctrl.parentTables.allocated = [{id: 3}];
expect(trunk.port_id).toEqual(3); var trunk = $scope.stepModels.trunkSlices.getParentPort();
expect(trunk.port_id).toEqual(3);
});
$timeout.flush();
}); });
it('should throw exception if more than on port is allocated', function() { it('should throw exception if more than one port is allocated', function() {
ctrl.parentTables.allocated = [{id: 3}, {id: 4}]; $scope.getPortsWithNets.then(function() {
expect(scope.stepModels.trunkSlices.getParentPort).toThrow(); ctrl.parentTables.allocated = [{id: 3}, {id: 4}];
expect($scope.stepModels.trunkSlices.getParentPort).toThrow();
});
$timeout.flush();
}); });
it('should remove port from available list if subportstable changes', function() { it('should remove port from available list if subportstable changes', function() {
spyOn(scope, '$broadcast').and.callThrough(); $scope.getPortsWithNets = $q.when([
{id: 1, admin_state_up: true, device_owner: ''},
{id: 2, admin_state_up: true, device_owner: ''},
{id: 3, admin_state_up: true, device_owner: ''}
]);
$scope.stepModels.allocated = {};
$scope.stepModels.allocated.subports = [{id: 3}];
ctrl.parentTables.available = [{id: 1}, {id: 2}, {id: 3}]; $scope.getPortsWithNets.then(function() {
scope.stepModels.allocated.subports = [{id: 3}]; ctrl.portsLoaded = true;
scope.$digest(); expect(ctrl.parentTables.available).toEqual([
{id: 1, admin_state_up: true, device_owner: ''},
expect(scope.$broadcast).toHaveBeenCalledWith( {id: 2, admin_state_up: true, device_owner: ''}
ttevents.TABLES_CHANGED, ]);
{data: {available: [{id: 1}, {id: 2}]}} });
); $scope.$digest();
expect(ctrl.parentTables.available).toEqual([{id: 1}, {id: 2}]);
}); });
}); });

View File

@@ -7,159 +7,165 @@
be created. Mandatory. be created. Mandatory.
</p> </p>
<transfer-table tr-model="ctrl.parentTables" help-text="ctrl.tableHelpText" limits="ctrl.tableLimits"> <div ng-if="!ctrl.portsLoaded">
<allocated ng-model="ctrl.parentTables.allocated.length" validate-number-min="1"> <span translate class="subtitle text-info">
<table st-table="ctrl.parentTables.displayedAllocated" st-safe-src="ctrl.parentTables.allocated" Loading ports... Please Wait
hz-table class="table table-striped table-rsp table-detail"> </span>
<thead> </div>
<tr>
<th class="expander"></th>
<th st-sort="name" st-sort-default class="rsp-p1" translate>Name</th>
<th class="rsp-p2" translate>IP</th>
<th st-sort="admin_state" class="rsp-p1" translate>Admin State</th>
<th st-sort="status" class="rsp-p1" translate>Status</th>
<th class="actions_column"></th>
</tr>
</thead>
<tbody>
<tr ng-if="ctrl.parentTables.allocated.length === 0">
<td colspan="7">
<div class="no-rows-help" translate>
Select an item from Available items below
</div>
</td>
</tr>
<tr ng-repeat-start="item in ctrl.parentTables.displayedAllocated track by item.id"
lr-drag-data="ctrl.parentTables.displayedAllocated"
lr-drop-success="trCtrl.updateAllocated(e, item, collection)">
<td class="expander">
<span class="fa fa-chevron-right" hz-expand-detail
title="{$ 'Click to see more details'|translate $}"></span>
</td>
<td class="rsp-p1 word-break">{$ ctrl.nameOrID(item) $}</td>
<td class="rsp-p2">
<div ng-repeat="ip in item.fixed_ips">
{$ ip.ip_address $} <span translate>on subnet</span>: {$ item.subnet_names[ip.ip_address] $}
</div>
</td>
<td class="rsp-p1">{$ item.admin_state | decode:ctrl.portAdminStates $}</td>
<td class="rsp-p1">{$ item.status | decode:ctrl.portStatuses $}</td>
<td class="actions_column">
<action-list>
<action action-classes="'btn btn-default'"
callback="trCtrl.deallocate" item="item">
<span class="fa fa-arrow-down"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td colspan="7" class="detail">
<dl class="dl-horizontal">
<dt translate>ID</dt>
<dd>{$ item.id $}</dd>
<dt translate>Project ID</dt>
<dd>{$ item.tenant_id $}</dd>
<dt translate>Network ID</dt>
<dd>{$ item.network_id $}</dd>
<dt translate>Network</dt>
<dd>{$ item.network_name $}</dd>
<dt translate>MAC Address</dt>
<dd>{$ item.mac_address $}</dd>
<dt translate>Device Owner</dt>
<dd>{$ item.device_owner $}</dd>
<dt translate>Device ID</dt>
<dd>{$ item.device_id $}</dd>
<dt translate>VNIC type</dt>
<dd>{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}</dd>
<div ng-if="item['binding:host_id']">
<dt translate>Host ID</dt>
<dd>{$ item['binding:host_id'] $}</dd>
</div>
</dl>
</td>
</tr>
</tbody>
</table>
</allocated>
<available> <div ng-if="ctrl.portsLoaded">
<table st-table="ctrl.parentTables.displayedAvailable" st-safe-src="ctrl.parentTables.available" <transfer-table tr-model="ctrl.parentTables" help-text="ctrl.tableHelpText" limits="ctrl.tableLimits">
hz-table class="table table-striped table-rsp table-detail"> <allocated ng-model="ctrl.parentTables.allocated.length" validate-number-min="1">
<thead> <table st-table="ctrl.parentTables.displayedAllocated" st-safe-src="ctrl.parentTables.allocated"
<tr> hz-table class="table table-striped table-rsp table-detail">
<th class="search-header" colspan="6"> <thead>
<hz-search-bar icon-classes="fa-search"></hz-search-bar> <tr>
</th> <th class="expander"></th>
</tr> <th st-sort="name" st-sort-default class="rsp-p1" translate>Name</th>
<tr> <th class="rsp-p2" translate>IP</th>
<th class="expander"></th> <th st-sort="admin_state" class="rsp-p1" translate>Admin State</th>
<th st-sort="name" st-sort-default class="rsp-p1" translate>Name</th> <th st-sort="status" class="rsp-p1" translate>Status</th>
<th class="rsp-p2" translate>IP</th> <th class="actions_column"></th>
<th st-sort="admin_state" class="rsp-p1" translate>Admin State</th> </tr>
<th st-sort="status" class="rsp-p1" translate>Status</th> </thead>
<th class="actions_column"></th> <tbody>
</tr> <tr ng-if="ctrl.parentTables.allocated.length === 0">
</thead> <td colspan="7">
<tbody> <div class="no-rows-help" translate>
<tr ng-if="trCtrl.numAvailable() === 0"> Select an item from Available items below
<td colspan="6">
<div class="no-rows-help" translate>
No available items
</div>
</td>
</tr>
<tr ng-repeat-start="item in ctrl.parentTables.displayedAvailable track by item.id"
ng-if="!trCtrl.allocatedIds[item.id]">
<td class="expander">
<span class="fa fa-chevron-right" hz-expand-detail
title="{$ 'Click to see more details'|translate $}"></span>
</td>
<td class="rsp-p1 word-break">{$ ctrl.nameOrID(item) $}</td>
<td class="rsp-p2">
<div ng-repeat="ip in item.fixed_ips">
{$ ip.ip_address $} <span translate>on subnet</span>: {$ item.subnet_names[ip.ip_address] $}
</div>
</td>
<td class="rsp-p1">{$ item.admin_state | decode:ctrl.portAdminStates $}</td>
<td class="rsp-p1">{$ item.status | decode:ctrl.portStatuses $}</td>
<td class="actions_column">
<action-list>
<action action-classes="'btn btn-default'"
callback="trCtrl.allocate" item="item">
<span class="fa fa-arrow-up"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td colspan="6" class="detail">
<dl class="dl-horizontal">
<dt translate>ID</dt>
<dd>{$ item.id $}</dd>
<dt translate>Project ID</dt>
<dd>{$ item.tenant_id $}</dd>
<dt translate>Network ID</dt>
<dd>{$ item.network_id $}</dd>
<dt translate>Network</dt>
<dd>{$ item.network_name $}</dd>
<dt translate>MAC Address</dt>
<dd>{$ item.mac_address $}</dd>
<dt translate>Device Owner</dt>
<dd>{$ item.device_owner $}</dd>
<dt translate>Device ID</dt>
<dd>{$ item.device_id $}</dd>
<dt translate>VNIC type</dt>
<dd>{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}</dd>
<div ng-if="item['binding:host_id']">
<dt translate>Host ID</dt>
<dd>{$ item['binding:host_id'] $}</dd>
</div> </div>
</dl> </td>
</td> </tr>
</tr> <tr ng-repeat-start="item in ctrl.parentTables.displayedAllocated track by item.id"
</tbody> lr-drag-data="ctrl.parentTables.displayedAllocated"
</table> lr-drop-success="trCtrl.updateAllocated(e, item, collection)">
</available> <td class="expander">
</transfer-table> <span class="fa fa-chevron-right" hz-expand-detail
title="{$ 'Click to see more details'|translate $}"></span>
</td>
<td class="rsp-p1 word-break">{$ ctrl.nameOrID(item) $}</td>
<td class="rsp-p2">
<div ng-repeat="ip in item.fixed_ips">
{$ ip.ip_address $} <span translate>on subnet</span>: {$ item.subnet_names[ip.ip_address] $}
</div>
</td>
<td class="rsp-p1">{$ item.admin_state | decode:ctrl.portAdminStates $}</td>
<td class="rsp-p1">{$ item.status | decode:ctrl.portStatuses $}</td>
<td class="actions_column">
<action-list>
<action action-classes="'btn btn-default'"
callback="trCtrl.deallocate" item="item">
<span class="fa fa-arrow-down"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td colspan="7" class="detail">
<dl class="dl-horizontal">
<dt translate>ID</dt>
<dd>{$ item.id $}</dd>
<dt translate>Project ID</dt>
<dd>{$ item.tenant_id $}</dd>
<dt translate>Network ID</dt>
<dd>{$ item.network_id $}</dd>
<dt translate>Network</dt>
<dd>{$ item.network_name $}</dd>
<dt translate>MAC Address</dt>
<dd>{$ item.mac_address $}</dd>
<dt translate>Device Owner</dt>
<dd>{$ item.device_owner $}</dd>
<dt translate>Device ID</dt>
<dd>{$ item.device_id $}</dd>
<dt translate>VNIC type</dt>
<dd>{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}</dd>
<div ng-if="item['binding:host_id']">
<dt translate>Host ID</dt>
<dd>{$ item['binding:host_id'] $}</dd>
</div>
</dl>
</td>
</tr>
</tbody>
</table>
</allocated>
<available>
<table st-table="ctrl.parentTables.displayedAvailable" st-safe-src="ctrl.parentTables.available"
hz-table class="table table-striped table-rsp table-detail">
<thead>
<tr>
<th class="search-header" colspan="6">
<hz-search-bar icon-classes="fa-search"></hz-search-bar>
</th>
</tr>
<tr>
<th class="expander"></th>
<th st-sort="name" st-sort-default class="rsp-p1" translate>Name</th>
<th class="rsp-p2" translate>IP</th>
<th st-sort="admin_state" class="rsp-p1" translate>Admin State</th>
<th st-sort="status" class="rsp-p1" translate>Status</th>
<th class="actions_column"></th>
</tr>
</thead>
<tbody>
<tr ng-if="trCtrl.numAvailable() === 0">
<td colspan="6">
<div class="no-rows-help" translate>
No available items
</div>
</td>
</tr>
<tr ng-repeat-start="item in ctrl.parentTables.displayedAvailable track by item.id"
ng-if="!trCtrl.allocatedIds[item.id]">
<td class="expander">
<span class="fa fa-chevron-right" hz-expand-detail
title="{$ 'Click to see more details'|translate $}"></span>
</td>
<td class="rsp-p1 word-break">{$ ctrl.nameOrID(item) $}</td>
<td class="rsp-p2">
<div ng-repeat="ip in item.fixed_ips">
{$ ip.ip_address $} <span translate>on subnet</span>: {$ item.subnet_names[ip.ip_address] $}
</div>
</td>
<td class="rsp-p1">{$ item.admin_state | decode:ctrl.portAdminStates $}</td>
<td class="rsp-p1">{$ item.status | decode:ctrl.portStatuses $}</td>
<td class="actions_column">
<action-list>
<action action-classes="'btn btn-default'"
callback="trCtrl.allocate" item="item">
<span class="fa fa-arrow-up"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td colspan="6" class="detail">
<dl class="dl-horizontal">
<dt translate>ID</dt>
<dd>{$ item.id $}</dd>
<dt translate>Project ID</dt>
<dd>{$ item.tenant_id $}</dd>
<dt translate>Network ID</dt>
<dd>{$ item.network_id $}</dd>
<dt translate>Network</dt>
<dd>{$ item.network_name $}</dd>
<dt translate>MAC Address</dt>
<dd>{$ item.mac_address $}</dd>
<dt translate>Device ID</dt>
<dd>{$ item.device_id $}</dd>
<dt translate>VNIC type</dt>
<dd>{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}</dd>
<div ng-if="item['binding:host_id']">
<dt translate>Host ID</dt>
<dd>{$ item['binding:host_id'] $}</dd>
</div>
</dl>
</td>
</tr>
</tbody>
</table>
</available>
</transfer-table>
</div>
</div> </div>

View File

@@ -31,6 +31,7 @@
TrunkSubPortsController.$inject = [ TrunkSubPortsController.$inject = [
'$scope', '$scope',
'horizon.app.core.trunks.actions.ports-extra.service',
'horizon.app.core.trunks.portConstants', 'horizon.app.core.trunks.portConstants',
'horizon.framework.widgets.action-list.button-tooltip.row-warning.service', 'horizon.framework.widgets.action-list.button-tooltip.row-warning.service',
'horizon.framework.widgets.transfer-table.events' 'horizon.framework.widgets.transfer-table.events'
@@ -38,11 +39,13 @@
function TrunkSubPortsController( function TrunkSubPortsController(
$scope, $scope,
portsExtra,
portConstants, portConstants,
tooltipService, tooltipService,
ttevents ttevents
) { ) {
var ctrl = this; var ctrl = this;
var subportCandidates;
ctrl.portStatuses = portConstants.statuses; ctrl.portStatuses = portConstants.statuses;
ctrl.portAdminStates = portConstants.adminStates; ctrl.portAdminStates = portConstants.adminStates;
@@ -71,80 +74,92 @@
ctrl.segmentationTypes = Object.keys(ctrl.segmentationTypesDict); ctrl.segmentationTypes = Object.keys(ctrl.segmentationTypesDict);
ctrl.subportsDetails = {}; ctrl.subportsDetails = {};
$scope.initTrunk.sub_ports.forEach(function(subport) { $scope.getTrunk.then(function(trunk) {
ctrl.subportsDetails[subport.port_id] = { trunk.sub_ports.forEach(function(subport) {
segmentation_type: subport.segmentation_type, ctrl.subportsDetails[subport.port_id] = {
segmentation_id: subport.segmentation_id segmentation_type: subport.segmentation_type,
segmentation_id: subport.segmentation_id
};
});
ctrl.trunkLoaded = true;
});
$scope.getPortsWithNets.then(function(portsWithNets) {
var subportsOfInitTrunk = portsWithNets.filter(
portsExtra.isSubportOfTrunk.bind(null, $scope.initTrunk.id));
subportCandidates = portsWithNets.filter(
portsExtra.isSubportCandidate);
ctrl.subportsTables = {
available: [].concat(subportsOfInitTrunk, subportCandidates),
allocated: [].concat(subportsOfInitTrunk),
displayedAvailable: [],
displayedAllocated: []
}; };
});
ctrl.subportsTables = { // See also in the details step controller.
available: [].concat( $scope.stepModels.trunkSlices = $scope.stepModels.trunkSlices || {};
$scope.ports.subportsOfInitTrunk, $scope.stepModels.trunkSlices.getSubports = function() {
$scope.ports.subportCandidates), var trunk = {sub_ports: []};
// NOTE(bence romsics): Trunk information merged into ports and trunk
// information in initTrunk may occasionally be out of sync. Theoratically
// there's a chance to get rid of this, but that refactor will go deep.
allocated: $scope.ports.subportsOfInitTrunk,
displayedAvailable: [],
displayedAllocated: []
};
// See also in the details step controller. ctrl.subportsTables.allocated.forEach(function(port) {
$scope.stepModels.trunkSlices = $scope.stepModels.trunkSlices || {}; // Subport information comes from two sources, one handled by
$scope.stepModels.trunkSlices.getSubports = function() { // transfertable, the other from outside of transfertable. We
var trunk = {sub_ports: []}; // may see the two data structures in an inconsistent state. We
// skip the inconsistent cases by the following condition.
if (port.id in ctrl.subportsDetails) {
trunk.sub_ports.push({
port_id: port.id,
segmentation_id: ctrl.subportsDetails[port.id].segmentation_id,
segmentation_type: ctrl.subportsDetails[port.id].segmentation_type
});
}
});
ctrl.subportsTables.allocated.forEach(function(port) { return trunk;
// Subport information comes from two sources, one handled by };
// transfertable, the other from outside of transfertable. We
// may see the two data structures in an inconsistent state. We // We expose the allocated table directly to the parent port step
// skip the inconsistent cases by the following condition. // controller, so it can set watchers on it and react accordingly...
if (port.id in ctrl.subportsDetails) { $scope.stepModels.allocated = $scope.stepModels.allocated || {};
trunk.sub_ports.push({ $scope.stepModels.allocated.subports = ctrl.subportsTables.allocated;
port_id: port.id,
segmentation_id: ctrl.subportsDetails[port.id].segmentation_id, // ...and vice versa.
segmentation_type: ctrl.subportsDetails[port.id].segmentation_type var deregisterAllocatedWatcher = $scope.$watchCollection(
}); 'stepModels.allocated.parentPort', hideAllocated);
}
$scope.$on('$destroy', function() {
deregisterAllocatedWatcher();
}); });
return trunk; function hideAllocated(allocatedList) {
}; if (!ctrl.portsLoaded || !allocatedList) {
return;
// We expose the allocated table directly to the parent port step
// controller, so it can set watchers on it and react accordingly...
$scope.stepModels.allocated = $scope.stepModels.allocated || {};
$scope.stepModels.allocated.subports = ctrl.subportsTables.allocated;
// ...and vice versa.
var deregisterAllocatedWatcher = $scope.$watchCollection(
'stepModels.allocated.parentPort', hideAllocated);
$scope.$on('$destroy', function() {
deregisterAllocatedWatcher();
});
function hideAllocated(allocatedList) {
var allocatedDict = {};
var availableList;
allocatedList.forEach(function(port) {
allocatedDict[port.id] = true;
});
availableList = $scope.ports.subportCandidates.filter(
function(port) {
return !(port.id in allocatedDict);
} }
);
ctrl.subportsTables.available = availableList; var allocatedDict = {};
// Notify transfertable. var availableList;
$scope.$broadcast(
ttevents.TABLES_CHANGED, allocatedList.forEach(function(port) {
{data: {available: availableList}} allocatedDict[port.id] = true;
); });
} availableList = subportCandidates.filter(
function(port) {
return !(port.id in allocatedDict);
}
);
ctrl.subportsTables.available = availableList;
// Notify transfertable.
$scope.$broadcast(
ttevents.TABLES_CHANGED,
{data: {available: availableList}}
);
}
ctrl.portsLoaded = true;
});
} }
})(); })();

View File

@@ -23,22 +23,25 @@
beforeEach(module('horizon.app.core.trunks')); beforeEach(module('horizon.app.core.trunks'));
describe('TrunkSubPortsController', function() { describe('TrunkSubPortsController', function() {
var scope, ctrl, ttevents; var $q, $timeout, $scope, ctrl;
beforeEach(inject(function($rootScope, $controller, $injector) { beforeEach(inject(function(_$q_, _$timeout_, $rootScope, $controller) {
scope = $rootScope.$new(); $q = _$q_;
scope.ports = { $timeout = _$timeout_;
subportCandidates: [{id: 1}, {id: 2}], $scope = $rootScope.$new();
subportsOfInitTrunk: [] $scope.getPortsWithNets = $q.when([
}; {id: 1, admin_state_up: true, device_owner: ''},
scope.stepModels = {}; {id: 2, admin_state_up: true, device_owner: ''}
scope.initTrunk = { ]);
$scope.stepModels = {};
var trunk = {
sub_ports: [] sub_ports: []
}; };
ttevents = $injector.get('horizon.framework.widgets.transfer-table.events'); $scope.initTrunk = trunk;
$scope.getTrunk = $q.when(trunk);
ctrl = $controller('TrunkSubPortsController', { ctrl = $controller('TrunkSubPortsController', {
$scope: scope $scope: $scope
}); });
})); }));
@@ -78,12 +81,17 @@
}); });
it('uses scope to set table data', function() { it('uses scope to set table data', function() {
expect(ctrl.subportsTables).toBeDefined(); $scope.getPortsWithNets.then(function() {
expect(ctrl.subportsTables.available).toEqual( expect(ctrl.subportsTables).toBeDefined();
[{id: 1}, {id: 2}]); expect(ctrl.subportsTables.available).toEqual([
expect(ctrl.subportsTables.allocated).toEqual([]); {id: 1, admin_state_up: true, device_owner: ''},
expect(ctrl.subportsTables.displayedAllocated).toEqual([]); {id: 2, admin_state_up: true, device_owner: ''}
expect(ctrl.subportsTables.displayedAvailable).toEqual([]); ]);
expect(ctrl.subportsTables.allocated).toEqual([]);
expect(ctrl.subportsTables.displayedAllocated).toEqual([]);
expect(ctrl.subportsTables.displayedAvailable).toEqual([]);
});
$timeout.flush();
}); });
it('has segmentation types dict', function() { it('has segmentation types dict', function() {
@@ -101,65 +109,82 @@
}); });
it('should return with subports', function() { it('should return with subports', function() {
ctrl.subportsTables.allocated = [{id: 3}, {id: 4}, {id: 5}]; $scope.getPortsWithNets.then(function() {
ctrl.subportsDetails = { ctrl.subportsTables.allocated = [{id: 3}, {id: 4}, {id: 5}];
3: {segmentation_type: 'VLAN', segmentation_id: 100}, ctrl.subportsDetails = {
4: {segmentation_type: 'VLAN', segmentation_id: 101} 3: {segmentation_type: 'VLAN', segmentation_id: 100},
}; 4: {segmentation_type: 'VLAN', segmentation_id: 101}
var subports = scope.stepModels.trunkSlices.getSubports(); };
expect(subports).toEqual({ var subports = $scope.stepModels.trunkSlices.getSubports();
sub_ports: [ expect(subports).toEqual({
{port_id: 3, segmentation_id: 100, segmentation_type: 'VLAN'}, sub_ports: [
{port_id: 4, segmentation_id: 101, segmentation_type: 'VLAN'} {port_id: 3, segmentation_id: 100, segmentation_type: 'VLAN'},
] {port_id: 4, segmentation_id: 101, segmentation_type: 'VLAN'}
]
});
}); });
$timeout.flush();
}); });
it('should remove port from available list if parenttable changes', function() { it('should remove port from available list if parenttable changes', function() {
spyOn(scope, '$broadcast').and.callThrough(); $scope.getPortsWithNets = $q.when([
{id: 1, admin_state_up: true, device_owner: ''},
{id: 2, admin_state_up: true, device_owner: ''},
{id: 3, admin_state_up: true, device_owner: ''}
]);
$scope.stepModels.allocated = {};
$scope.stepModels.allocated.parentPort = [{id: 3}];
ctrl.subportsTables.available = [{id: 1}, {id: 2}, {id: 3}]; $scope.getPortsWithNets.then(function() {
scope.stepModels.allocated.parentPort = [{id: 3}]; ctrl.portsLoaded = true;
scope.$digest(); expect(ctrl.subportsTables.available).toEqual([
{id: 1, admin_state_up: true, device_owner: ''},
expect(scope.$broadcast).toHaveBeenCalledWith( {id: 2, admin_state_up: true, device_owner: ''}
ttevents.TABLES_CHANGED, ]);
{data: {available: [{id: 1}, {id: 2}]}} });
); $scope.$digest();
expect(ctrl.subportsTables.available).toEqual([{id: 1}, {id: 2}]);
}); });
it('should add to allocated list the subports of the edited trunk', function() { it('should add to allocated list the subports of the edited trunk', function() {
inject(function($rootScope, $controller) { inject(function($rootScope, $controller) {
scope = $rootScope.$new(); $scope = $rootScope.$new();
scope.ports = { $scope.getPortsWithNets = $q.when([
subportCandidates: [{id: 1}, {id: 4}], {id: 1, admin_state_up: true, device_owner: ''},
subportsOfInitTrunk: [{id: 4, segmentation_id: 2, segmentation_type: 'vlan'}] {id: 4, admin_state_up: true, device_owner: '', trunk_id: 1}
}; ]);
scope.stepModels = {}; $scope.stepModels = {};
scope.initTrunk = { var trunk = {
sub_ports: [{port_id: 4, segmentation_type: 'vlan', segmentation_id: 2}] id: 1,
sub_ports: [
{port_id: 4, segmentation_type: 'vlan', segmentation_id: 2}
]
}; };
$scope.initTrunk = trunk;
$scope.getTrunk = $q.when(trunk);
ctrl = $controller('TrunkSubPortsController', { ctrl = $controller('TrunkSubPortsController', {
$scope: scope $scope: $scope
}); });
}); });
expect(ctrl.subportsDetails).toBeDefined(); $scope.getTrunk.then(function() {
expect(ctrl.subportsDetails).toEqual({ expect(ctrl.subportsDetails).toBeDefined();
4: { expect(ctrl.subportsDetails).toEqual({
segmentation_id: 2, 4: {
segmentation_type: 'vlan' segmentation_id: 2,
} segmentation_type: 'vlan'
}
});
}); });
$scope.getPortsWithNets.then(function() {
var subports = scope.stepModels.trunkSlices.getSubports(); var subports = $scope.stepModels.trunkSlices.getSubports();
expect(subports).toEqual({ expect(subports).toEqual({
sub_ports: [ sub_ports: [
{port_id: 4, segmentation_id: 2, segmentation_type: 'vlan'} {port_id: 4, segmentation_id: 2, segmentation_type: 'vlan'}
] ]
});
}); });
$timeout.flush();
}); });
}); });

View File

@@ -8,173 +8,181 @@
Optional. Optional.
</p> </p>
<transfer-table tr-model="ctrl.subportsTables" help-text="ctrl.tableHelpText" limits="ctrl.tableLimits"> <div ng-if="!ctrl.portsLoaded">
<allocated> <span translate class="subtitle text-info">
<table st-table="ctrl.subportsTables.displayedAllocated" st-safe-src="ctrl.subportsTables.allocated" Loading ports... Please Wait
hz-table class="table table-striped table-rsp table-detail"> </span>
<thead> </div>
<tr>
<th class="expander"></th>
<th st-sort="name" st-sort-default class="rsp-p1" translate>Name</th>
<th class="rsp-p2" translate>IP</th>
<th st-sort="admin_state" class="rsp-p1" translate>Admin State</th>
<th st-sort="status" class="rsp-p1" translate>Status</th>
<th class="rsp-p1" translate>Segmentation Type</th>
<th class="rsp-p1" translate>Segmentation Id</th>
<th class="actions_column"></th>
</tr>
</thead>
<tbody>
<tr ng-if="ctrl.subportsTables.allocated.length === 0">
<td colspan="7">
<div class="no-rows-help" translate>
Select items from Available items below
</div>
</td>
</tr>
<tr ng-repeat-start="item in ctrl.subportsTables.displayedAllocated track by item.id"
lr-drag-data="ctrl.subportsTables.displayedAllocated"
lr-drop-success="trCtrl.updateAllocated(e, item, collection)">
<td class="expander">
<span class="fa fa-chevron-right" hz-expand-detail
title="{$ 'Click to see more details'|translate $}"></span>
</td>
<td class="rsp-p1 word-break">{$ ctrl.nameOrID(item) $}</td>
<td class="rsp-p2">
<div ng-repeat="ip in item.fixed_ips">
{$ ip.ip_address $} <span translate>on subnet</span>: {$ item.subnet_names[ip.ip_address] $}
</div>
</td>
<td class="rsp-p1">{$ item.admin_state | decode:ctrl.portAdminStates $}</td>
<td class="rsp-p1">{$ item.status | decode:ctrl.portStatuses $}</td>
<td class="rsp-p1">
<select id="segmentation_type_{$ $index $}"
ng-init="ctrl.subportsDetails[item.id]['segmentation_type'] = ctrl.segmentationTypes[0]"
ng-options="type for type in ctrl.segmentationTypes"
ng-model="ctrl.subportsDetails[item.id]['segmentation_type']">
</select>
</td>
<td class="rsp-p1" ng-if="ctrl.subportsDetails[item.id]['segmentation_type'] === 'inherit'" translate>
inherit
</td>
<td class="rsp-p1" ng-if="ctrl.subportsDetails[item.id]['segmentation_type'] !== 'inherit'">
<!-- NOTE(bence romsics): We could but do not reject non-integer
segmentation IDs. It does not seem worth to add one more
directive for that effect. -->
<input type="number"
id="segmentation_id_{$ $index $}"
min="{$ ctrl.segmentationTypesDict[ctrl.subportsDetails[item.id]['segmentation_type']][0] $}"
max="{$ ctrl.segmentationTypesDict[ctrl.subportsDetails[item.id]['segmentation_type']][1] $}"
ng-model="ctrl.subportsDetails[item.id]['segmentation_id']" required>
</td>
<td class="actions_column">
<action-list>
<action action-classes="'btn btn-default'"
callback="trCtrl.deallocate" item="item">
<span class="fa fa-arrow-down"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td colspan="7" class="detail">
<dl class="dl-horizontal">
<dt translate>ID</dt>
<dd>{$ item.id $}</dd>
<dt translate>Project ID</dt>
<dd>{$ item.tenant_id $}</dd>
<dt translate>Network ID</dt>
<dd>{$ item.network_id $}</dd>
<dt translate>Network</dt>
<dd>{$ item.network_name $}</dd>
<dt translate>MAC Address</dt>
<dd>{$ item.mac_address $}</dd>
<dt translate>VNIC type</dt>
<dd>{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}</dd>
<div ng-if="item['binding:host_id']">
<dt translate>Host ID</dt>
<dd>{$ item['binding:host_id'] $}</dd>
</div>
</dl>
</td>
</tr>
</tbody>
</table>
</allocated>
<available> <div ng-if="ctrl.portsLoaded">
<table st-table="ctrl.subportsTables.displayedAvailable" st-safe-src="ctrl.subportsTables.available" <transfer-table tr-model="ctrl.subportsTables" help-text="ctrl.tableHelpText" limits="ctrl.tableLimits">
hz-table class="table table-striped table-rsp table-detail"> <allocated>
<thead> <table st-table="ctrl.subportsTables.displayedAllocated" st-safe-src="ctrl.subportsTables.allocated"
<tr> hz-table class="table table-striped table-rsp table-detail">
<th class="search-header" colspan="6"> <thead>
<hz-search-bar icon-classes="fa-search"></hz-search-bar> <tr>
</th> <th class="expander"></th>
</tr> <th st-sort="name" st-sort-default class="rsp-p1" translate>Name</th>
<tr> <th class="rsp-p2" translate>IP</th>
<th class="expander"></th> <th st-sort="admin_state" class="rsp-p1" translate>Admin State</th>
<th st-sort="name" st-sort-default class="rsp-p1" translate>Name</th> <th st-sort="status" class="rsp-p1" translate>Status</th>
<th class="rsp-p2" translate>IP</th> <th class="rsp-p1" translate>Segmentation Type</th>
<th st-sort="admin_state" class="rsp-p1" translate>Admin State</th> <th class="rsp-p1" translate>Segmentation Id</th>
<th st-sort="status" class="rsp-p1" translate>Status</th> <th class="actions_column"></th>
<th class="actions_column"></th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> <tr ng-if="ctrl.subportsTables.allocated.length === 0">
<tr ng-if="trCtrl.numAvailable() === 0"> <td colspan="7">
<td colspan="6"> <div class="no-rows-help" translate>
<div class="no-rows-help" translate> Select items from Available items below
No available items
</div>
</td>
</tr>
<tr ng-repeat-start="item in ctrl.subportsTables.displayedAvailable track by item.id"
ng-if="!trCtrl.allocatedIds[item.id]">
<td class="expander">
<span class="fa fa-chevron-right" hz-expand-detail
title="{$ 'Click to see more details'|translate $}"></span>
</td>
<td class="rsp-p1 word-break">{$ ctrl.nameOrID(item) $}</td>
<td class="rsp-p2">
<div ng-repeat="ip in item.fixed_ips">
{$ ip.ip_address $} <span translate>on subnet</span>: {$ item.subnet_names[ip.ip_address] $}
</div>
</td>
<td class="rsp-p1">{$ item.admin_state | decode:ctrl.portAdminStates $}</td>
<td class="rsp-p1">{$ item.status | decode:ctrl.portStatuses $}</td>
<td class="actions_column">
<action-list>
<action action-classes="'btn btn-default'"
callback="trCtrl.allocate" item="item">
<span class="fa fa-arrow-up"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td colspan="6" class="detail">
<dl class="dl-horizontal">
<dt translate>ID</dt>
<dd>{$ item.id $}</dd>
<dt translate>Project ID</dt>
<dd>{$ item.tenant_id $}</dd>
<dt translate>Network ID</dt>
<dd>{$ item.network_id $}</dd>
<dt translate>Network</dt>
<dd>{$ item.network_name $}</dd>
<dt translate>MAC Address</dt>
<dd>{$ item.mac_address $}</dd>
<dt translate>VNIC type</dt>
<dd>{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}</dd>
<div ng-if="item['binding:host_id']">
<dt translate>Host ID</dt>
<dd>{$ item['binding:host_id'] $}</dd>
</div> </div>
</dl> </td>
</td> </tr>
</tr> <tr ng-repeat-start="item in ctrl.subportsTables.displayedAllocated track by item.id"
</tbody> lr-drag-data="ctrl.subportsTables.displayedAllocated"
</table> lr-drop-success="trCtrl.updateAllocated(e, item, collection)">
</available> <td class="expander">
</transfer-table> <span class="fa fa-chevron-right" hz-expand-detail
title="{$ 'Click to see more details'|translate $}"></span>
</td>
<td class="rsp-p1 word-break">{$ ctrl.nameOrID(item) $}</td>
<td class="rsp-p2">
<div ng-repeat="ip in item.fixed_ips">
{$ ip.ip_address $} <span translate>on subnet</span>: {$ item.subnet_names[ip.ip_address] $}
</div>
</td>
<td class="rsp-p1">{$ item.admin_state | decode:ctrl.portAdminStates $}</td>
<td class="rsp-p1">{$ item.status | decode:ctrl.portStatuses $}</td>
<td class="rsp-p1">
<select id="segmentation_type_{$ $index $}"
ng-init="ctrl.subportsDetails[item.id]['segmentation_type'] = ctrl.segmentationTypes[0]"
ng-options="type for type in ctrl.segmentationTypes"
ng-model="ctrl.subportsDetails[item.id]['segmentation_type']">
</select>
</td>
<td class="rsp-p1" ng-if="ctrl.subportsDetails[item.id]['segmentation_type'] === 'inherit'" translate>
inherit
</td>
<td class="rsp-p1" ng-if="ctrl.subportsDetails[item.id]['segmentation_type'] !== 'inherit'">
<!-- NOTE(bence romsics): We could but do not reject non-integer
segmentation IDs. It does not seem worth to add one more
directive for that effect. -->
<input type="number"
id="segmentation_id_{$ $index $}"
min="{$ ctrl.segmentationTypesDict[ctrl.subportsDetails[item.id]['segmentation_type']][0] $}"
max="{$ ctrl.segmentationTypesDict[ctrl.subportsDetails[item.id]['segmentation_type']][1] $}"
ng-model="ctrl.subportsDetails[item.id]['segmentation_id']" required>
</td>
<td class="actions_column">
<action-list>
<action action-classes="'btn btn-default'"
callback="trCtrl.deallocate" item="item">
<span class="fa fa-arrow-down"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td colspan="7" class="detail">
<dl class="dl-horizontal">
<dt translate>ID</dt>
<dd>{$ item.id $}</dd>
<dt translate>Project ID</dt>
<dd>{$ item.tenant_id $}</dd>
<dt translate>Network ID</dt>
<dd>{$ item.network_id $}</dd>
<dt translate>Network</dt>
<dd>{$ item.network_name $}</dd>
<dt translate>MAC Address</dt>
<dd>{$ item.mac_address $}</dd>
<dt translate>VNIC type</dt>
<dd>{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}</dd>
<div ng-if="item['binding:host_id']">
<dt translate>Host ID</dt>
<dd>{$ item['binding:host_id'] $}</dd>
</div>
</dl>
</td>
</tr>
</tbody>
</table>
</allocated>
<available>
<table st-table="ctrl.subportsTables.displayedAvailable" st-safe-src="ctrl.subportsTables.available"
hz-table class="table table-striped table-rsp table-detail">
<thead>
<tr>
<th class="search-header" colspan="6">
<hz-search-bar icon-classes="fa-search"></hz-search-bar>
</th>
</tr>
<tr>
<th class="expander"></th>
<th st-sort="name" st-sort-default class="rsp-p1" translate>Name</th>
<th class="rsp-p2" translate>IP</th>
<th st-sort="admin_state" class="rsp-p1" translate>Admin State</th>
<th st-sort="status" class="rsp-p1" translate>Status</th>
<th class="actions_column"></th>
</tr>
</thead>
<tbody>
<tr ng-if="trCtrl.numAvailable() === 0">
<td colspan="6">
<div class="no-rows-help" translate>
No available items
</div>
</td>
</tr>
<tr ng-repeat-start="item in ctrl.subportsTables.displayedAvailable track by item.id"
ng-if="!trCtrl.allocatedIds[item.id]">
<td class="expander">
<span class="fa fa-chevron-right" hz-expand-detail
title="{$ 'Click to see more details'|translate $}"></span>
</td>
<td class="rsp-p1 word-break">{$ ctrl.nameOrID(item) $}</td>
<td class="rsp-p2">
<div ng-repeat="ip in item.fixed_ips">
{$ ip.ip_address $} <span translate>on subnet</span>: {$ item.subnet_names[ip.ip_address] $}
</div>
</td>
<td class="rsp-p1">{$ item.admin_state | decode:ctrl.portAdminStates $}</td>
<td class="rsp-p1">{$ item.status | decode:ctrl.portStatuses $}</td>
<td class="actions_column">
<action-list>
<action action-classes="'btn btn-default'"
callback="trCtrl.allocate" item="item">
<span class="fa fa-arrow-up"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td colspan="6" class="detail">
<dl class="dl-horizontal">
<dt translate>ID</dt>
<dd>{$ item.id $}</dd>
<dt translate>Project ID</dt>
<dd>{$ item.tenant_id $}</dd>
<dt translate>Network ID</dt>
<dd>{$ item.network_id $}</dd>
<dt translate>Network</dt>
<dd>{$ item.network_name $}</dd>
<dt translate>MAC Address</dt>
<dd>{$ item.mac_address $}</dd>
<dt translate>VNIC type</dt>
<dd>{$ item['binding:vnic_type'] | decode:ctrl.vnicTypes $}</dd>
<div ng-if="item['binding:host_id']">
<dt translate>Host ID</dt>
<dd>{$ item['binding:host_id'] $}</dd>
</div>
</dl>
</td>
</tr>
</tbody>
</table>
</available>
</transfer-table>
</div>
</div> </div>