Migrate swift ui to use hz-dynamic-table

This removes the custom table code and uses the new
hz-dynamic-table directive to manage the table.

The documentation for actionResultService was also
edited to improve clarity.

Note: I was not intending to migrate all actions over
to use actionResultService in this patch (to keep the
patch size under control) so only the delete actions
have been done, and even then not optimally. These will
be revisited in a subsequent patch.

Change-Id: If8c4009c29fbbdbbeac12ce2bee4dcbef287ea98
Closes-Bug: 1576418
Closes-Bug: 1586175
Partially-Implements: blueprint swift-ui-functionality
This commit is contained in:
Richard Jones 2016-06-06 16:22:56 +10:00
parent fedb991435
commit 241cf1bd7e
12 changed files with 592 additions and 537 deletions

View File

@ -25,61 +25,64 @@
* @ngdoc factory
* @name factory
* @description
* The purpose of this service is to conveniently create meaningful return
* values from an action. For example, if you perform an action that deletes
* three items, it may be useful for the action to return information
* that indicates which three items were deleted.
* The purpose of this service is to create action return values in a
* common manner, making it easier on consumers of such results. For
* example, if you perform an action that deletes three items, it may be
* useful for the action to return information that indicates which three
* items were deleted.
*
* The ActionResult object allows an action's code to easily indicate what
* items were affected.
* Create a new ActionResult object with:
* ```
* var actionResult = actionResultService.getActionResult()
* ```
*
* For example, let's say our action deleted three items. We would
* resolve the action's promise by appending three 'deleted' items, then
* conclude by returning the bare result object.
* @example
```
return actionResultService.getActionResult()
.deleted('OS::Glance::Image', id1)
.deleted('OS::Glance::Image', id2)
.deleted('OS::Glance::Image', id3)
.result;
```
* As an example of how this is consumed, imagine a situation where there is
* a display with a list of instances, each having actions. A user performs
* one action, let's say Edit Instance; then after the action completes, the
* user's expectation is that the list of instances is reloaded. The
* controller that is managing that display needs to have a hook into the
* user's action. This is achieved through returning a promise from the
* initiation of the action. In the case of the actions directive, the
* promise is handled through assigning a result-handler in the table row
* markup:
```
<actions allowed="ctrl.itemActions" type="row" item="currInstance"
result-handler="ctrl.actionResultHandler"></actions>
```
* The controller places a handler (above, ctrl.actionResultHandler) on this
* promise which, when the promise is resolved, analyzes that resolution
* to figure out logically what to do. We want to make this logic simple and
* also capable of handling 'unknown' actions; that is, we want to generically
* handle any action that a third-party could add. The action result
* feature provides this generic mechanism. The Edit Instance action would
* resolve with {updated: [{type: 'OS::Nova::Server', id: 'some-uuid'}]},
* which then can be handled by the controller as required. In a controller:
```
ctrl.actionResultHandler = function resultHandler(returnValue) {
return $q.when(returnValue, actionSuccessHandler);
};
function actionSuccessHandler(result) {
// simple logic to just reload any time there are updated results.
if (result.updated.length > 0) {
reloadTheList();
}
}
```
* This logic of course should probably be more fine-grained than the example,
* but this demonstrates the basics of how you use action promises and provide
* appropriate behaviors.
* The ActionResult object collects results under four categories: created,
* updated, deleted and failed. It has methods for registering results of
* each of those types:
* ```
* actionResult.deleted('OS::Glance::Image', id1);
* actionResult.updated('OS::Glance::Image', id2)
* .deleted('OS::Glance::Image', id3);
* ```
*
* These results are then accessed through the actionResult.result property
* which has four array properties, one for each category, listing objects
* with {type:, id:} from the above calls.
*
* To use actionResultService in an <actions> directive, you would have
* your directive register a result-handler:
* ```
* <actions allowed="ctrl.itemActions" type="row" item="currInstance"
* result-handler="ctrl.actionResultHandler"></actions>
* ```
*
* And then in the perform() method of the action, you would construct
* the actionResult as above, and return the actionResult.result from
* perform()'s final promise:
* ```
* function performUpdate(item) {
* $modal.open(updateDialog).result.then(function result() {
* return actionResult.updated('OS::Glance::Image', item.id).result;
* });
* }
* ```
*
* The controller's result handler (above, ctrl.actionResultHandler)
* analyzes that result to figure out what to do. We want to make this
* capable of handling actions which may return an immediate result
* or may return a promise, so in our controller we can use $q.when:
* ```
* ctrl.actionResultHandler = function resultHandler(returnValue) {
* return $q.when(returnValue, actionSuccessHandler);
* };
*
* function actionSuccessHandler(result) {
* // simple logic to just reload any time there are updated results.
* if (result.updated.length > 0) {
* reloadTheList();
* }
* }
* ```
*/
function factory() {

View File

@ -47,7 +47,9 @@
* (Optional) A function that is called with the return value from a clicked actions perform
* function. Ideally the action perform function returns a promise that resolves to some data
* on success, but it may return just data, or no return at all, depending on the specific action
* implementation.
* implementation. It is recommended to use the actionResultService to manage the results of your
* actions, and also to have them generate results which are more broadly usable than a custom
* result value.
*
* @param {function} allowed
* Returns an array of actions that can be performed on the item(s).

View File

@ -78,11 +78,7 @@
*/
function toggleSelect(row, checkedState, broadcast) {
var key = row[ctrl.trackId];
if (angular.isDefined(ctrl.selections[key])) {
ctrl.selections[key].checked = checkedState;
} else {
ctrl.selections[key] = { checked: checkedState, item: row };
}
ctrl.selections[key] = { checked: checkedState, item: row };
ctrl.selected = getSelected(ctrl.selections);
if (broadcast) {
/*

View File

@ -40,13 +40,7 @@
};
// collect files/folders
var items = [];
angular.forEach(selected, function each(item) {
if (item.checked) {
items.push(item.file);
}
});
model.recursiveCollect(ctrl.model, items, ctrl.model.collection)
model.recursiveCollect(ctrl.model, selected, ctrl.model.collection)
.then(function complete() {
ctrl.model.running = false;
});

View File

@ -52,14 +52,15 @@
});
it('should invoke recursiveCollect when created', function() {
var ctrl = createController([
{file: 'one', checked: true},
{file: 'two', checked: false},
{file: 'three', checked: true}
]);
var files = [
{name: 'one'},
{name: 'two'},
{name: 'three'}
];
var ctrl = createController(files);
expect(ctrl.model.running).toEqual(true);
expect(model.recursiveCollect).toHaveBeenCalledWith(ctrl.model,
['one', 'three'], ctrl.model.collection);
expect(model.recursiveCollect).toHaveBeenCalledWith(ctrl.model, files,
ctrl.model.collection);
collectDeferred.resolve();
$rootScope.$apply();
expect(ctrl.model.running).toEqual(false);

View File

@ -0,0 +1,198 @@
/*
* (c) Copyright 2016 Rackspace US, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function() {
'use strict';
angular
.module('horizon.dashboard.project.containers')
.factory('horizon.dashboard.project.containers.objects-batch-actions', batchActions)
.factory('horizon.dashboard.project.containers.objects-batch-actions.create-folder',
createFolderService)
.factory('horizon.dashboard.project.containers.objects-batch-actions.delete', deleteService)
.factory('horizon.dashboard.project.containers.objects-batch-actions.upload', uploadService);
batchActions.$inject = [
'horizon.dashboard.project.containers.objects-batch-actions.create-folder',
'horizon.dashboard.project.containers.objects-batch-actions.delete',
'horizon.dashboard.project.containers.objects-batch-actions.upload'
];
/**
* @ngdoc factory
* @name horizon.app.core.images.table.row-actions.service
* @description A list of row actions.
*/
function batchActions(
createFolderService,
deleteService,
uploadService
) {
return {
actions: actions
};
///////////////
function actions() {
return [
{
service: uploadService,
template: {text: '<span class="fa fa-upload"></span>'}
},
{
service: createFolderService,
template: {text: '<span class="fa fa-plus"></span>&nbsp;' + gettext('Folder')}
},
{
service: deleteService,
template: {text: '', type: 'delete-selected'}
}
];
}
}
function uploadModal(html, $modal) {
var localSpec = {
backdrop: 'static',
controller: 'UploadObjectModalController as ctrl',
templateUrl: html
};
return $modal.open(localSpec).result;
}
uploadService.$inject = [
'horizon.app.core.openstack-service-api.swift',
'horizon.dashboard.project.containers.basePath',
'horizon.dashboard.project.containers.containers-model',
'horizon.framework.util.q.extensions',
'horizon.framework.widgets.modal-wait-spinner.service',
'horizon.framework.widgets.toast.service',
'$modal'
];
function uploadService(swiftAPI, basePath, model, $qExtensions, modalWaitSpinnerService,
toastService, $modal) {
var service = {
allowed: function allowed() {
return $qExtensions.booleanAsPromise(true);
},
perform: function perform() {
uploadModal(basePath + 'upload-object-modal.html', $modal)
.then(service.uploadObjectCallback);
},
uploadObjectCallback: uploadObjectCallback
};
return service;
function uploadObjectCallback(uploadInfo) {
modalWaitSpinnerService.showModalSpinner(gettext("Uploading"));
swiftAPI.uploadObject(
model.container.name,
model.fullPath(uploadInfo.name),
uploadInfo.upload_file
).then(function success() {
modalWaitSpinnerService.hideModalSpinner();
toastService.add(
'success',
interpolate(gettext('File %(name)s uploaded.'), uploadInfo, true)
);
model.updateContainer();
model.selectContainer(
model.container.name,
model.folder
);
}, function error() {
modalWaitSpinnerService.hideModalSpinner();
});
}
}
createFolderService.$inject = [
'horizon.app.core.openstack-service-api.swift',
'horizon.dashboard.project.containers.basePath',
'horizon.dashboard.project.containers.containers-model',
'horizon.framework.util.q.extensions',
'horizon.framework.widgets.toast.service',
'$modal'
];
function createFolderService(swiftAPI, basePath, model, $qExtensions, toastService, $modal) {
var service = {
allowed: function allowed() {
return $qExtensions.booleanAsPromise(true);
},
perform: function perform() {
uploadModal(basePath + 'create-folder-modal.html', $modal)
.then(service.createFolderCallback);
},
createFolderCallback: createFolderCallback
};
return service;
function createFolderCallback(name) {
swiftAPI.createFolder(
model.container.name,
model.fullPath(name))
.then(
function success() {
toastService.add(
'success',
interpolate(gettext('Folder %(name)s created.'), {name: name}, true)
);
model.updateContainer();
model.selectContainer(
model.container.name,
model.folder
);
}
);
}
}
deleteService.$inject = [
'horizon.dashboard.project.containers.basePath',
'horizon.framework.util.actions.action-result.service',
'horizon.framework.util.q.extensions',
'$modal'
];
function deleteService(basePath, actionResultService, $qExtensions, $modal) {
return {
allowed: function allowed() {
return $qExtensions.booleanAsPromise(true);
},
perform: function perform(files) {
var localSpec = {
backdrop: 'static',
controller: 'DeleteObjectsModalController as ctrl',
templateUrl: basePath + 'delete-objects-modal.html',
resolve: {
selected: function () {
return files;
}
}
};
return $modal.open(localSpec).result.then(function finished() {
return actionResultService.getActionResult().deleted(
'OS::Swift::Object', 'DELETED'
).result;
});
}
};
}
})();

View File

@ -0,0 +1,245 @@
/**
* (c) Copyright 2016 Rackspace US, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
(function() {
'use strict';
describe('horizon.dashboard.project.containers objects batch actions', function test() {
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(module('horizon.framework'));
beforeEach(module('horizon.dashboard.project'));
var $window = {location: {href: 'ham'}};
beforeEach(module(function before($provide) {
$provide.constant('horizon.dashboard.project.containers.basePath', '/base/path/');
$provide.value('$window', $window);
}));
var batchActions, modalWaitSpinnerService, model, $q, $rootScope, swiftAPI, toast;
beforeEach(inject(function inject($injector, _$q_, _$rootScope_) {
batchActions = $injector.get('horizon.dashboard.project.containers.objects-batch-actions');
modalWaitSpinnerService = $injector.get(
'horizon.framework.widgets.modal-wait-spinner.service'
);
model = $injector.get('horizon.dashboard.project.containers.containers-model');
$q = _$q_;
$rootScope = _$rootScope_;
swiftAPI = $injector.get('horizon.app.core.openstack-service-api.swift');
toast = $injector.get('horizon.framework.widgets.toast.service');
// we never really want this to happen for realsies below
var deferred = $q.defer();
deferred.resolve();
spyOn(model, 'selectContainer').and.returnValue(deferred.promise);
// common spies
spyOn(model, 'updateContainer');
spyOn(modalWaitSpinnerService, 'showModalSpinner');
spyOn(modalWaitSpinnerService, 'hideModalSpinner');
spyOn(toast, 'add');
}));
it('should create an actions list', function test() {
expect(batchActions.actions).toBeDefined();
var actions = batchActions.actions();
expect(actions.length).toEqual(3);
angular.forEach(actions, function check(action) {
expect(action.service).toBeDefined();
expect(action.template).toBeDefined();
expect(action.template.text).toBeDefined();
});
});
describe('uploadService', function test() {
var $modal, uploadService;
beforeEach(inject(function inject($injector, _$modal_) {
$modal = _$modal_;
uploadService = $injector.get(
'horizon.dashboard.project.containers.objects-batch-actions.upload'
);
}));
it('should have an allowed and perform', function test() {
expect(uploadService.allowed).toBeDefined();
expect(uploadService.perform).toBeDefined();
});
it('should allow upload', function test() {
expectAllowed(uploadService.allowed());
});
it('should create "upload file" modals', function test() {
var deferred = $q.defer();
var result = { result: deferred.promise };
spyOn($modal, 'open').and.returnValue(result);
model.container = {name: 'ham'};
spyOn(uploadService, 'uploadObjectCallback');
uploadService.perform();
expect($modal.open).toHaveBeenCalled();
var spec = $modal.open.calls.mostRecent().args[0];
expect(spec.backdrop).toBeDefined();
expect(spec.controller).toBeDefined();
expect(spec.templateUrl).toEqual('/base/path/upload-object-modal.html');
deferred.resolve('new-file');
$rootScope.$apply();
expect(uploadService.uploadObjectCallback).toHaveBeenCalledWith('new-file');
});
it('should upload files', function test() {
// uploadObjectCallback is quite complex, so we have a bit to mock out
var deferred = $q.defer();
spyOn(swiftAPI, 'uploadObject').and.returnValue(deferred.promise);
model.container = {name: 'spam'};
model.folder = 'ham';
uploadService.uploadObjectCallback({upload_file: 'file', name: 'eggs.txt'});
expect(modalWaitSpinnerService.showModalSpinner).toHaveBeenCalled();
expect(swiftAPI.uploadObject).toHaveBeenCalledWith(
'spam', 'ham/eggs.txt', 'file'
);
// the swift API returned
deferred.resolve();
$rootScope.$apply();
expect(toast.add).toHaveBeenCalledWith('success', 'File eggs.txt uploaded.');
expect(model.selectContainer).toHaveBeenCalledWith('spam', 'ham');
expect(modalWaitSpinnerService.hideModalSpinner).toHaveBeenCalled();
expect(model.updateContainer).toHaveBeenCalled();
});
});
describe('createFolderService', function test() {
var $modal, createFolderService;
beforeEach(inject(function inject($injector, _$modal_) {
$modal = _$modal_;
createFolderService = $injector.get(
'horizon.dashboard.project.containers.objects-batch-actions.create-folder'
);
}));
it('should have an allowed and perform', function test() {
expect(createFolderService.allowed).toBeDefined();
expect(createFolderService.perform).toBeDefined();
});
it('should allow upload', function test() {
expectAllowed(createFolderService.allowed());
});
it('should create "create folder" modals', function test() {
var deferred = $q.defer();
var result = {result: deferred.promise};
spyOn($modal, 'open').and.returnValue(result);
spyOn(createFolderService, 'createFolderCallback');
createFolderService.perform();
expect($modal.open).toHaveBeenCalled();
var spec = $modal.open.calls.mostRecent().args[0];
expect(spec.backdrop).toBeDefined();
expect(spec.controller).toBeDefined();
expect(spec.templateUrl).toEqual('/base/path/create-folder-modal.html');
deferred.resolve('new-folder');
$rootScope.$apply();
expect(createFolderService.createFolderCallback).toHaveBeenCalledWith('new-folder');
});
it('should create folders', function test() {
var deferred = $q.defer();
spyOn(swiftAPI, 'createFolder').and.returnValue(deferred.promise);
model.container = {name: 'spam'};
model.folder = 'ham';
createFolderService.createFolderCallback('new-folder');
expect(swiftAPI.createFolder).toHaveBeenCalledWith('spam', 'ham/new-folder');
deferred.resolve();
$rootScope.$apply();
expect(toast.add).toHaveBeenCalledWith('success', 'Folder new-folder created.');
expect(model.selectContainer).toHaveBeenCalledWith('spam', 'ham');
expect(model.updateContainer).toHaveBeenCalled();
});
});
describe('deleteService', function test() {
var actionResultService, deleteService, $modal, $q;
beforeEach(inject(function inject($injector, _$q_, _$modal_) {
actionResultService = $injector.get('horizon.framework.util.actions.action-result.service');
deleteService = $injector.get(
'horizon.dashboard.project.containers.objects-batch-actions.delete'
);
$modal = _$modal_;
$q = _$q_;
}));
it('should have an allowed and perform', function test() {
expect(deleteService.allowed).toBeDefined();
expect(deleteService.perform).toBeDefined();
});
it('should always allow', function test() {
expectAllowed(deleteService.allowed());
});
it('should confirm bulk deletion with a modal', function test() {
// deferred to be resolved then the modal is "closed" in a bit
var deferred = $q.defer();
var result = {result: deferred.promise};
spyOn($modal, 'open').and.returnValue(result);
spyOn(actionResultService, 'getActionResult').and.callThrough();
deleteService.perform(['one', 'two']);
expect($modal.open).toHaveBeenCalled();
var spec = $modal.open.calls.mostRecent().args[0];
expect(spec.backdrop).toBeDefined();
expect(spec.controller).toEqual('DeleteObjectsModalController as ctrl');
expect(spec.templateUrl).toEqual('/base/path/delete-objects-modal.html');
// "close" the modal, make sure delete is called
deferred.resolve();
$rootScope.$apply();
expect(actionResultService.getActionResult).toHaveBeenCalled();
});
});
function exerciseAllowedPromise(promise) {
var handler = jasmine.createSpyObj('handler', ['success', 'error']);
promise.then(handler.success, handler.error);
$rootScope.$apply();
return handler;
}
function expectAllowed(promise) {
var handler = exerciseAllowedPromise(promise);
expect(handler.success).toHaveBeenCalled();
expect(handler.error).not.toHaveBeenCalled();
}
});
})();

View File

@ -122,12 +122,12 @@
deleteService.$inject = [
'horizon.dashboard.project.containers.basePath',
'$modal',
'horizon.dashboard.project.containers.containers-model',
'horizon.framework.util.q.extensions'
'horizon.framework.util.actions.action-result.service',
'horizon.framework.util.q.extensions',
'$modal'
];
function deleteService(basePath, $modal, model, $qExtensions) {
function deleteService(basePath, actionResultService, $qExtensions, $modal) {
return {
allowed: function allowed() {
return $qExtensions.booleanAsPromise(true);
@ -139,20 +139,15 @@
templateUrl: basePath + 'delete-objects-modal.html',
resolve: {
selected: function () {
return [{checked: true, file: file}];
return [file];
}
}
};
return $modal.open(localSpec).result.then(function finished() {
// remove the deleted file/folder from display
for (var i = model.objects.length - 1; i >= 0; i--) {
if (model.objects[i].name === file.name) {
model.objects.splice(i, 1);
break;
}
}
model.updateContainer();
return actionResultService.getActionResult().deleted(
'OS::Swift::Object', file.name
).result;
});
}
};

View File

@ -127,9 +127,10 @@
});
describe('deleteService', function test() {
var deleteService, $q;
var actionResultService, deleteService, $q;
beforeEach(inject(function inject($injector, _$q_) {
actionResultService = $injector.get('horizon.framework.util.actions.action-result.service');
deleteService = $injector.get(
'horizon.dashboard.project.containers.objects-actions.delete'
);
@ -150,8 +151,7 @@
var deferred = $q.defer();
var result = { result: deferred.promise };
spyOn($modal, 'open').and.returnValue(result);
spyOn(model, 'updateContainer');
model.objects = [{name: 'ham'}, {name: 'too'}];
spyOn(actionResultService, 'getActionResult').and.callThrough();
deleteService.perform({name: 'ham'});
$rootScope.$apply();
@ -162,13 +162,12 @@
expect(spec.templateUrl).toBeDefined();
expect(spec.resolve).toBeDefined();
expect(spec.resolve.selected).toBeDefined();
expect(spec.resolve.selected()).toEqual([{checked: true, file: {name: 'ham'}}]);
expect(spec.resolve.selected()).toEqual([{name: 'ham'}]);
// "close" the modal, make sure delete is called
deferred.resolve();
$rootScope.$apply();
expect(model.updateContainer).toHaveBeenCalled();
expect(model.objects).toEqual([{name: 'too'}]);
expect(actionResultService.getActionResult).toHaveBeenCalled();
});
});

View File

@ -30,27 +30,24 @@
.controller('horizon.dashboard.project.containers.ObjectsController', ObjectsController);
ObjectsController.$inject = [
'horizon.app.core.openstack-service-api.swift',
'horizon.dashboard.project.containers.containers-model',
'horizon.dashboard.project.containers.containerRoute',
'horizon.dashboard.project.containers.basePath',
'horizon.dashboard.project.containers.objects-batch-actions',
'horizon.dashboard.project.containers.objects-row-actions',
'horizon.framework.widgets.modal-wait-spinner.service',
'horizon.framework.widgets.toast.service',
'$modal',
'$q',
'$routeParams'
'$routeParams',
'$scope'
];
function ObjectsController(swiftAPI, containersModel, containerRoute, basePath, rowActions,
modalWaitSpinnerService, toastService,
$modal, $q, $routeParams)
function ObjectsController(containersModel, containerRoute, batchActions,
rowActions, $q, $routeParams, $scope)
{
var ctrl = this;
ctrl.rowActions = rowActions;
ctrl.batchActions = batchActions;
ctrl.model = containersModel;
ctrl.selected = {};
ctrl.numSelected = 0;
ctrl.containerURL = containerRoute + encodeURIComponent($routeParams.container) +
@ -73,56 +70,41 @@
});
});
ctrl.anySelectable = anySelectable;
ctrl.isSelected = isSelected;
ctrl.selectAll = selectAll;
ctrl.clearSelected = clearSelected;
ctrl.toggleSelect = toggleSelect;
ctrl.deleteSelected = deleteSelected;
ctrl.createFolder = createFolder;
ctrl.createFolderCallback = createFolderCallback;
ctrl.filterFacets = [
{
label: gettext('Name'),
name: 'name',
singleton: true
}
];
ctrl.tableConfig = {
selectAll: true,
expand: false,
trackId: 'name',
searchColumnSpan: 6,
columns: [
{
id: 'name', title: 'Name', priority: 1, sortDefault: true,
template: '<a ng-if="item.is_subdir" ng-href="{$ table.objectURL(item) $}">' +
'{$ item.name $}</a><span ng-if="item.is_object">{$ item.name $}</span>'
},
{
id: 'size', title: 'Size', priority: 1,
template: '<span ng-if="item.is_object">{$item.bytes | bytes$}</span>' +
'<span ng-if="item.is_subdir" translate>Folder</span>'
}
]
};
ctrl.getBreadcrumbs = getBreadcrumbs;
ctrl.objectURL = objectURL;
ctrl.uploadObject = uploadObject;
ctrl.uploadObjectCallback = uploadObjectCallback;
ctrl.actionResultHandler = function actionResultHandler(returnValue) {
return $q.when(returnValue, actionSuccessHandler);
};
//////////
function anySelectable() {
return ctrl.model.objects.length > 0;
}
function isSelected(file) {
var state = ctrl.selected[file.name];
return angular.isDefined(state) && state.checked;
}
function selectAll() {
ctrl.clearSelected();
angular.forEach(ctrl.model.objects, function each(file) {
ctrl.selected[file.name] = {checked: true, file: file};
ctrl.numSelected++;
});
}
function clearSelected() {
ctrl.selected = {};
ctrl.numSelected = 0;
}
function toggleSelect(file) {
var checkedState = !ctrl.isSelected(file);
ctrl.selected[file.name] = {
checked: checkedState,
file: file
};
if (checkedState) {
ctrl.numSelected++;
} else {
ctrl.numSelected--;
}
}
function getBreadcrumbs() {
var crumbs = [];
var encoded = ctrl.model.pseudo_folder_hierarchy.map(encodeURIComponent);
@ -139,91 +121,18 @@
return ctrl.currentURL + encodeURIComponent(file.name);
}
function deleteSelected() {
var localSpec = {
backdrop: 'static',
controller: 'DeleteObjectsModalController as ctrl',
templateUrl: basePath + 'delete-objects-modal.html',
resolve: {
selected: function () {
return ctrl.selected;
}
}
};
// do the follow-up regardless of success or error
return $modal.open(localSpec).result.finally(function finished() {
// remove the checked files/folders from display
for (var i = ctrl.model.objects.length - 1; i >= 0; i--) {
if (ctrl.isSelected(ctrl.model.objects[i])) {
ctrl.model.objects.splice(i, 1);
}
}
ctrl.clearSelected();
function actionSuccessHandler(result) {
if (!angular.isDefined(result)) {
return;
}
if (result.deleted.length > 0) {
$scope.$broadcast('hzTable:clearSelected');
ctrl.model.updateContainer();
});
}
function uploadModal(html) {
var localSpec = {
backdrop: 'static',
controller: 'UploadObjectModalController as ctrl',
templateUrl: basePath + html
};
return $modal.open(localSpec).result;
}
function createFolder() {
uploadModal('create-folder-modal.html').then(ctrl.createFolderCallback);
}
function createFolderCallback(name) {
swiftAPI.createFolder(
ctrl.model.container.name,
ctrl.model.fullPath(name))
.then(
function success() {
toastService.add(
'success',
interpolate(gettext('Folder %(name)s created.'), {name: name}, true)
);
ctrl.model.updateContainer();
// TODO optimize me
ctrl.model.selectContainer(
ctrl.model.container.name,
ctrl.model.folder
);
}
);
}
// TODO consider https://github.com/nervgh/angular-file-upload
function uploadObject() {
uploadModal('upload-object-modal.html').then(ctrl.uploadObjectCallback);
}
function uploadObjectCallback(info) {
modalWaitSpinnerService.showModalSpinner(gettext("Uploading"));
swiftAPI.uploadObject(
ctrl.model.container.name,
ctrl.model.fullPath(info.name),
info.upload_file
).then(function success() {
modalWaitSpinnerService.hideModalSpinner();
toastService.add(
'success',
interpolate(gettext('File %(name)s uploaded.'), info, true)
);
ctrl.model.updateContainer();
// TODO optimize me
ctrl.model.selectContainer(
ctrl.model.container.name,
ctrl.model.folder
);
}, function error() {
modalWaitSpinnerService.hideModalSpinner();
});
}
}
}
})();

View File

@ -28,30 +28,18 @@
$provide.constant('horizon.dashboard.project.containers.containerRoute', 'eggs/');
}));
var $modal, $q, $scope, $routeParams, controller, modalWaitSpinnerService, model,
swiftAPI, toast;
var $q, $scope, $routeParams, controller, model;
beforeEach(inject(function inject($injector, _$q_, _$rootScope_) {
controller = $injector.get('$controller');
$modal = $injector.get('$modal');
$q = _$q_;
$scope = _$rootScope_.$new();
modalWaitSpinnerService = $injector.get(
'horizon.framework.widgets.modal-wait-spinner.service'
);
model = $injector.get('horizon.dashboard.project.containers.containers-model');
swiftAPI = $injector.get('horizon.app.core.openstack-service-api.swift');
toast = $injector.get('horizon.framework.widgets.toast.service');
// we never really want this to happen for realsies below
var deferred = $q.defer();
deferred.resolve();
spyOn(model, 'selectContainer').and.returnValue(deferred.promise);
// common spies
spyOn(modalWaitSpinnerService, 'showModalSpinner');
spyOn(modalWaitSpinnerService, 'hideModalSpinner');
spyOn(toast, 'add');
}));
function createController(folder) {
@ -103,218 +91,5 @@
expect(model.selectContainer).toHaveBeenCalledWith('spam', 'ham');
});
it('should determine "any" selectability', function test() {
var ctrl = createController();
ctrl.model.objects = [{}, {}];
expect(ctrl.anySelectable()).toEqual(true);
});
it('should determine "any" selectability with none', function test() {
var ctrl = createController();
ctrl.model.objects = [];
expect(ctrl.anySelectable()).toEqual(false);
});
it('should determine whether files are selected if none selected', function test() {
var ctrl = createController();
ctrl.selected = {};
expect(ctrl.isSelected({name: 'one'})).toEqual(false);
});
it('should determine whether files are selected if others selected', function test() {
var ctrl = createController();
ctrl.selected = {two: {checked: true}};
expect(ctrl.isSelected({name: 'one'})).toEqual(false);
});
it('should determine whether files are selected if selected', function test() {
var ctrl = createController();
ctrl.selected = {one: {checked: true}};
expect(ctrl.isSelected({name: 'one'})).toEqual(true);
});
it('should determine whether files are selected if not selected', function test() {
var ctrl = createController();
ctrl.selected = {one: {checked: false}};
expect(ctrl.isSelected({name: 'one'})).toEqual(false);
});
it('should toggle selected state on', function test() {
var ctrl = createController();
ctrl.selected = {};
ctrl.numSelected = 0;
ctrl.toggleSelect({name: 'one'});
expect(ctrl.selected.one.checked).toEqual(true);
expect(ctrl.numSelected).toEqual(1);
});
it('should toggle selected state off', function test() {
var ctrl = createController();
ctrl.selected = {one: {checked: true}};
ctrl.numSelected = 1;
ctrl.toggleSelect({name: 'one'});
expect(ctrl.selected.one.checked).toEqual(false);
expect(ctrl.numSelected).toEqual(0);
});
it('should select all', function test() {
var ctrl = createController();
spyOn(ctrl, 'clearSelected').and.callThrough();
ctrl.selected = {some: 'stuff'};
ctrl.numSelected = 1;
ctrl.model.objects = [
{name: 'one'},
{name: 'two'}
];
ctrl.selectAll();
expect(ctrl.clearSelected).toHaveBeenCalled();
expect(ctrl.selected).toEqual({
one: {checked: true, file: {name: 'one'}},
two: {checked: true, file: {name: 'two'}}
});
expect(ctrl.numSelected).toEqual(2);
});
it('should confirm bulk deletion with a modal', function test() {
// deferred to be resolved then the modal is "closed" in a bit
var deferred = $q.defer();
var result = { result: deferred.promise };
spyOn($modal, 'open').and.returnValue(result);
var ctrl = createController();
spyOn(ctrl, 'clearSelected');
spyOn(model, 'updateContainer');
ctrl.model.objects = [{name: 'one'}, {name: 'two'}, {name: 'three'}];
ctrl.selected = {
one: {file: {name: 'one'}, checked: false},
two: {file: {name: 'two'}, checked: true}
};
ctrl.numSelected = 1;
ctrl.deleteSelected();
expect($modal.open).toHaveBeenCalled();
var spec = $modal.open.calls.mostRecent().args[0];
expect(spec.controller).toBeDefined();
expect(spec.templateUrl).toBeDefined();
expect(spec.resolve).toBeDefined();
expect(spec.resolve.selected).toBeDefined();
expect(spec.resolve.selected()).toEqual(ctrl.selected);
// "close" the modal, make sure delete is called
deferred.resolve();
$scope.$apply();
expect(ctrl.clearSelected).toHaveBeenCalled();
expect(model.updateContainer).toHaveBeenCalled();
// selectec objects should have been removed
expect(ctrl.model.objects.length).toEqual(2);
});
it('should create "create folder" modals', function test() {
var deferred = $q.defer();
var result = { result: deferred.promise };
spyOn($modal, 'open').and.returnValue(result);
var ctrl = createController();
spyOn(ctrl, 'createFolderCallback');
ctrl.createFolder();
expect($modal.open).toHaveBeenCalled();
var spec = $modal.open.calls.mostRecent().args[0];
expect(spec.backdrop).toBeDefined();
expect(spec.controller).toBeDefined();
expect(spec.templateUrl).toEqual('/base/path/create-folder-modal.html');
deferred.resolve('new-folder');
$scope.$apply();
expect(ctrl.createFolderCallback).toHaveBeenCalledWith('new-folder');
});
it('should create folders', function test() {
var deferred = $q.defer();
spyOn(swiftAPI, 'createFolder').and.returnValue(deferred.promise);
spyOn(model, 'updateContainer');
var ctrl = createController('ham');
ctrl.createFolderCallback('new-folder');
expect(swiftAPI.createFolder).toHaveBeenCalledWith('spam', 'ham/new-folder');
deferred.resolve();
$scope.$apply();
expect(toast.add).toHaveBeenCalledWith('success', 'Folder new-folder created.');
expect(model.selectContainer).toHaveBeenCalledWith('spam', 'ham');
expect(model.updateContainer).toHaveBeenCalled();
});
it('should create "upload file" modals', function test() {
var deferred = $q.defer();
var result = { result: deferred.promise };
spyOn($modal, 'open').and.returnValue(result);
var ctrl = createController();
spyOn(ctrl, 'uploadObjectCallback');
ctrl.uploadObject();
expect($modal.open).toHaveBeenCalled();
var spec = $modal.open.calls.mostRecent().args[0];
expect(spec.backdrop).toBeDefined();
expect(spec.controller).toBeDefined();
expect(spec.templateUrl).toEqual('/base/path/upload-object-modal.html');
deferred.resolve('new-file');
$scope.$apply();
expect(ctrl.uploadObjectCallback).toHaveBeenCalledWith('new-file');
});
it('should upload files', function test() {
// uploadObjectCallback is quite complex, so we have a bit to mock out
var deferred = $q.defer();
spyOn(swiftAPI, 'uploadObject').and.returnValue(deferred.promise);
spyOn(model, 'updateContainer');
var ctrl = createController('ham');
ctrl.uploadObjectCallback({upload_file: 'file', name: 'eggs.txt'});
expect(modalWaitSpinnerService.showModalSpinner).toHaveBeenCalled();
expect(swiftAPI.uploadObject).toHaveBeenCalledWith(
'spam', 'ham/eggs.txt', 'file'
);
// the swift API returned
deferred.resolve();
$scope.$apply();
expect(toast.add).toHaveBeenCalledWith('success', 'File eggs.txt uploaded.');
expect(model.selectContainer).toHaveBeenCalledWith('spam', 'ham');
expect(modalWaitSpinnerService.hideModalSpinner).toHaveBeenCalled();
expect(model.updateContainer).toHaveBeenCalled();
});
it('should clear the spinner on file upload error', function test() {
// uploadObjectCallback is quite complex, so we have a bit to mock out
var deferred = $q.defer();
spyOn(swiftAPI, 'uploadObject').and.returnValue(deferred.promise);
spyOn(model, 'updateContainer');
var ctrl = createController('ham');
ctrl.uploadObjectCallback({upload_file: 'file', name: 'eggs.txt'});
expect(modalWaitSpinnerService.showModalSpinner).toHaveBeenCalled();
expect(swiftAPI.uploadObject).toHaveBeenCalledWith(
'spam', 'ham/eggs.txt', 'file'
);
// HERE is the difference from the previous test
deferred.reject();
$scope.$apply();
expect(modalWaitSpinnerService.hideModalSpinner).toHaveBeenCalled();
expect(model.updateContainer).not.toHaveBeenCalled();
});
});
})();

View File

@ -1,85 +1,23 @@
<table class="table hz-objects table-hover table-striped"
ng-controller="horizon.dashboard.project.containers.ObjectsController as oc"
st-table="displayContents" st-safe-src="oc.model.objects"
hz-table default-sort="name">
<thead>
<tr class="page_title table_caption">
<th class="table_header" colspan="3">
<ol class="breadcrumb hz-object-path">
<li class="h4">
<a ng-href="{$ oc.containerURL $}">{$ oc.model.container.name $}</a>
</li>
<li ng-repeat="crumb in oc.breadcrumbs track by $index" ng-class="{'active':$last}">
<span>
<a ng-href="{$ crumb.url $}" ng-if="!$last">{$ crumb.label $}</a>
<span ng-if="$last">{$ crumb.label $}</span>
</span>
</li>
</ol>
</th>
</tr>
<div ng-controller="horizon.dashboard.project.containers.ObjectsController as oc">
<ol class="breadcrumb hz-object-path">
<li class="h4">
<a ng-href="{$ oc.containerURL $}">{$ oc.model.container.name $}</a>
</li>
<li ng-repeat="crumb in oc.breadcrumbs track by $index" ng-class="{'active':$last}">
<span>
<a ng-href="{$ crumb.url $}" ng-if="!$last">{$ crumb.label $}</a>
<span ng-if="$last">{$ crumb.label $}</span>
</span>
</li>
</ol>
<tr class="table_caption">
<th colspan="3" class="table_header search-header">
<hz-search-bar group-classes="input-group-sm"
icon-classes="fa-search" input-classes="form-control" placeholder="Filter">
</hz-search-bar>
</th>
</tr>
<tr class="table_caption">
<th colspan="3" class="table_header">
<div class="table_actions">
<a href="" ng-disabled="!oc.anySelectable()" ng-click="oc.selectAll()"
class="btn btn-default"translate >
Select All
</a>
<a href="" ng-click="oc.clearSelected()" class="btn btn-default"
ng-disabled="oc.numSelected == 0">
<translate>Clear Selection</translate>
<span ng-if="oc.numSelected > 0" class="badge">{$ oc.numSelected $}</span>
</a>
<a href="" ng-click="oc.createFolder()" class="btn btn-default">
<span class="fa fa-plus"></span>
<translate>Folder</translate>
</a>
<a href="" ng-click="oc.uploadObject()" tooltip="{$ 'Upload File' | translate $}"
tooltip-placement="top" tooltip-trigger="mouseenter" class="btn btn-default">
<span class="fa fa-upload"></span>
</a>
<!-- extra div (span doesn't work) so the tooltip shows even when the button's disabled -->
<div class="tooltip-hack" tooltip="{$ 'Delete Selection' | translate $}"
tooltip-placement="top" tooltip-trigger="mouseenter">
<button ng-disabled="oc.numSelected === 0" class="btn btn-default btn-danger"
ng-click="oc.deleteSelected(selected)">
<span class="fa fa-trash"></span>
</button>
</div>
</div>
</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="file in displayContents track by $index"
ng-class="{success: oc.isSelected(file)}"
ng-click="oc.toggleSelect(file)">
<td>
<a ng-if="file.is_subdir" ng-href="{$ oc.objectURL(file) $}">{$ file.name $}</a>
<span ng-if="file.is_object">{$ file.name $}</span>
</td>
<td class="text-right">
<span ng-if="file.is_object">{$file.bytes | bytes$}</span>
<span ng-if="file.is_subdir" translate>Folder</span>
</td>
<td class="actions_column">
<actions allowed="oc.rowActions.actions" type="row" item="file">
</actions>
</td>
</tr>
<tr hz-no-items items="displayContents">
</tr>
</tbody>
<tfoot hz-table-footer items="displayContents"></tfoot>
</table>
<hz-dynamic-table
config="oc.tableConfig"
items="oc.model.objects"
table="oc"
filter-facets="oc.filterFacets"
batch-actions="oc.batchActions.actions"
item-actions="oc.rowActions.actions"
result-handler="oc.actionResultHandler">
</hz-dynamic-table>
</div>