Implement file update (edit) in Swift UI

The ability to update (edit contents of) an object was
never present in the previous Swift UI. It was
explicitly blocked due to code in the swift_upload_object()
function, which has been removed in this patch. To
replace that "upload would replace existing contents"
check, this patch implements a client-side check to warn
the user if the upload would do so.

Partially-Implements: blueprint swift-ui-functionality
Change-Id: I9fb57dda59322907f0661372f9ee223551ff8a6e
This commit is contained in:
Richard Jones 2016-06-22 14:22:14 +10:00
parent 72ad1e3fd5
commit 053d0a669e
18 changed files with 647 additions and 164 deletions

View File

@ -154,7 +154,7 @@ class Object(generic.View):
# note: not an AJAX request - the body will be raw file content # note: not an AJAX request - the body will be raw file content
@csrf_exempt @csrf_exempt
def post(self, request, container, object_name): def post(self, request, container, object_name):
"""Create a new object or pseudo-folder """Create or replace an object or pseudo-folder
:param request: :param request:
:param container: :param container:
@ -176,7 +176,6 @@ class Object(generic.View):
data = form.clean() data = form.clean()
try:
if object_name[-1] == '/': if object_name[-1] == '/':
result = api.swift.swift_create_pseudo_folder( result = api.swift.swift_create_pseudo_folder(
request, request,
@ -190,9 +189,6 @@ class Object(generic.View):
object_name, object_name,
data['file'] data['file']
) )
except exceptions.AlreadyExists as e:
# 409 Conflict
return rest_utils.JSONResponse(str(e), 409)
return rest_utils.CreatedResponse( return rest_utils.CreatedResponse(
u'/api/swift/containers/%s/object/%s' % (container, result.name) u'/api/swift/containers/%s/object/%s' % (container, result.name)

View File

@ -272,8 +272,6 @@ def swift_copy_object(request, orig_container_name, orig_object_name,
def swift_upload_object(request, container_name, object_name, def swift_upload_object(request, container_name, object_name,
object_file=None): object_file=None):
if swift_object_exists(request, container_name, object_name):
raise exceptions.AlreadyExists(object_name, 'object')
headers = {} headers = {}
size = 0 size = 0
if object_file: if object_file:

View File

@ -0,0 +1,48 @@
/*
* (c) Copyright 2015 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')
.controller(
'horizon.dashboard.project.containers.EditObjectModalController',
EditObjectModalController
);
EditObjectModalController.$inject = ['fileDetails'];
function EditObjectModalController(fileDetails) {
var ctrl = this;
ctrl.model = {
container: fileDetails.container,
path: fileDetails.path,
view_file: null, // file object managed by angular form ngModel
edit_file: null // file object from the DOM element with the actual upload
};
ctrl.changeFile = changeFile;
///////////
function changeFile(files) {
if (files.length) {
// record the file selected for upload for use in the action that invoked this modal
ctrl.model.edit_file = files[0];
}
}
}
})();

View File

@ -0,0 +1,55 @@
/**
* (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 edit-object controller', function() {
var controller, ctrl;
beforeEach(module('horizon.framework'));
beforeEach(module('horizon.dashboard.project.containers'));
beforeEach(module(function ($provide) {
$provide.value('fileDetails', {
container: 'spam',
path: 'ham/eggs'
});
}));
beforeEach(inject(function ($injector) {
controller = $injector.get('$controller');
ctrl = controller('horizon.dashboard.project.containers.EditObjectModalController');
}));
it('should initialise the controller model when created', function test() {
expect(ctrl.model.path).toEqual('ham/eggs');
expect(ctrl.model.container).toEqual('spam');
});
it('should respond to file changes correctly', function test() {
var file = {name: 'eggs'};
ctrl.changeFile([file]);
expect(ctrl.model.edit_file).toEqual(file);
});
it('should not respond to file changes if no files are present', function test() {
ctrl.model.edit_file = 'spam';
ctrl.changeFile([]);
expect(ctrl.model.edit_file).toEqual('spam');
});
});
})();

View File

@ -0,0 +1,40 @@
<div class="modal-header">
<button type="button" class="close" ng-click="$dismiss()" aria-hidden="true" aria-label="Close">
<span aria-hidden="true" class="fa fa-times"></span>
</button>
<div class="h3 modal-title">
<translate>Edit File: {$ ctrl.model.container $} : {$ ctrl.model.path $}</translate>
</div>
</div>
<div ng-form="editForm">
<div class="modal-body">
<div class="row">
<div class="col-sm-6">
<fieldset>
<div class="form-group">
<label class="control-label required" for="id_object_file" translate>
New File Contents
</label>
<div>
<input id="id_object_file" type="file" name="file" required
ng-model="ctrl.model.view_file" on-file-change="ctrl.changeFile" />
</div>
</div>
</fieldset>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-default" ng-click="$dismiss()">
<span class="fa fa-close"></span>
<translate>Cancel</translate>
</button>
<button class="btn btn-primary" ng-click="$close(ctrl.model)"
ng-disabled="editForm.$invalid">
<span class="fa fa-upload"></span>
<translate>Edit File</translate>
</button>
</div>
</div>

View File

@ -16,6 +16,31 @@
(function () { (function () {
'use strict'; 'use strict';
/**
* @ngdoc directive
* @name on-file-change
* @element
* @description
* The `on-file-change` directive watches a file input and fires
* a callback when the file input is changed.
*
* The callback will be passed the "files" property from the
* browser event.
*
* @example
* ```
* <input type="file" ng-model="ctrl.file" on-file-change="ctrl.changeFile">
* <input type="text" ng-model="ctrl.file_name">
*
* function changeFile(files) {
* if (files.length) {
* // update the upload file & its name
* ctrl.upload_file = files[0];
* ctrl.file_name = files[0].name;
* }
* }
* ```
*/
angular angular
.module('horizon.dashboard.project.containers') .module('horizon.dashboard.project.containers')
.directive('onFileChange', OnFileChange); .directive('onFileChange', OnFileChange);

View File

@ -0,0 +1,74 @@
/*
* (c) Copyright 2015 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';
/**
* @ngdoc directive
* @name object-name-exists
* @element
* @description
* The `object-name-exists` directive is used on an angular form
* element to verify whether a Swift object name in the current context
* already exists or not. The current context (container name and
* folder) is taken from the container model service.
*
* If the name is taken, the ngModel will have $error.exists set
* (and all the other usual validation properties).
*
* Additionally since the check is asynchronous the ngModel
* will also have $pending.exists set while the lookup is being
* performed.
*
* @example
* ```
* <input type="text" name="name" ng-model="ctrl.name" object-name-exists>
* <span ng-show="ctrl.form.name.$pending.exists">Checking if this name is used...</span>
* <span ng-show="ctrl.form.name.$error.exists">This name already exists!</span>
* ```
*/
angular
.module('horizon.dashboard.project.containers')
.directive('objectNameExists', ObjectNameExists);
ObjectNameExists.$inject = [
'horizon.app.core.openstack-service-api.swift',
'horizon.dashboard.project.containers.containers-model',
'$q'
];
function ObjectNameExists(swiftAPI, model, $q) {
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, element, attrs, ngModel) {
ngModel.$asyncValidators.exists = function exists(modelValue) {
if (ngModel.$isEmpty(modelValue)) {
// consider empty model valid
return $q.when();
}
var def = $q.defer();
// reverse the sense here - successful lookup == error
swiftAPI
.getObjectDetails(model.container.name, model.fullPath(modelValue), true)
.then(def.reject, def.resolve);
return def.promise;
};
}
};
}
})();

View File

@ -0,0 +1,69 @@
/*
* (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 model', function() {
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(module('horizon.dashboard.project.containers'));
beforeEach(module('horizon.framework'));
var $compile, $scope, model, element, swiftAPI, apiDeferred;
beforeEach(inject(function inject($injector, _$q_, _$rootScope_) {
$scope = _$rootScope_.$new();
$compile = $injector.get('$compile');
model = $injector.get('horizon.dashboard.project.containers.containers-model');
swiftAPI = $injector.get('horizon.app.core.openstack-service-api.swift');
apiDeferred = _$q_.defer();
spyOn(swiftAPI, 'getObjectDetails').and.returnValue(apiDeferred.promise);
model.container = {name: 'spam'};
model.folder = 'ham';
$scope.model = '';
element = angular.element(
'<div ng-form="form">' +
'<input name="model" type="text" object-name-exists ng-model="model" />' +
'<span ng-if="form.model.$error.exists">EXISTS</span>' +
'</div>'
);
element = $compile(element)($scope);
$scope.$apply();
}));
it('should reject names that exist', function test() {
// edit the field
element.find('input').val('exists.txt').trigger('input');
expect(swiftAPI.getObjectDetails).toHaveBeenCalledWith('spam', 'ham/exists.txt', true);
// cause the lookup to complete successfully (file exists)
apiDeferred.resolve();
$scope.$apply();
expect(element.find('span').hasClass('ng-hide')).toEqual(false);
});
it('should accept names that do not exist', function test() {
// edit the field
element.find('input').val('not-exists.txt').trigger('input');
expect(swiftAPI.getObjectDetails).toHaveBeenCalledWith('spam', 'ham/not-exists.txt', true);
// cause the lookup to complete successfully (file exists)
apiDeferred.resolve();
$scope.$apply();
expect(element.find('span').hasClass('ng-hide')).toEqual(false);
});
});
})();

View File

@ -68,7 +68,7 @@
function uploadModal(html, $modal) { function uploadModal(html, $modal) {
var localSpec = { var localSpec = {
backdrop: 'static', backdrop: 'static',
controller: 'UploadObjectModalController as ctrl', controller: 'horizon.dashboard.project.containers.UploadObjectModalController as ctrl',
templateUrl: html templateUrl: html
}; };
return $modal.open(localSpec).result; return $modal.open(localSpec).result;

View File

@ -22,25 +22,25 @@
.factory('horizon.dashboard.project.containers.objects-row-actions', rowActions) .factory('horizon.dashboard.project.containers.objects-row-actions', rowActions)
.factory('horizon.dashboard.project.containers.objects-actions.delete', deleteService) .factory('horizon.dashboard.project.containers.objects-actions.delete', deleteService)
.factory('horizon.dashboard.project.containers.objects-actions.download', downloadService) .factory('horizon.dashboard.project.containers.objects-actions.download', downloadService)
.factory('horizon.dashboard.project.containers.objects-actions.edit', editService)
.factory('horizon.dashboard.project.containers.objects-actions.view', viewService); .factory('horizon.dashboard.project.containers.objects-actions.view', viewService);
rowActions.$inject = [ rowActions.$inject = [
'horizon.dashboard.project.containers.basePath',
'horizon.dashboard.project.containers.objects-actions.delete', 'horizon.dashboard.project.containers.objects-actions.delete',
'horizon.dashboard.project.containers.objects-actions.download', 'horizon.dashboard.project.containers.objects-actions.download',
'horizon.dashboard.project.containers.objects-actions.edit',
'horizon.dashboard.project.containers.objects-actions.view', 'horizon.dashboard.project.containers.objects-actions.view',
'horizon.framework.util.i18n.gettext' 'horizon.framework.util.i18n.gettext'
]; ];
/** /**
* @ngdoc factory * @ngdoc factory
* @name horizon.app.core.images.table.row-actions.service * @name horizon.app.core.images.table.row-actions.service
* @description A list of row actions. * @description A list of row actions.
*/ */
function rowActions( function rowActions(
basePath,
deleteService, deleteService,
downloadService, downloadService,
editService,
viewService, viewService,
gettext gettext
) { ) {
@ -56,6 +56,10 @@
service: downloadService, service: downloadService,
template: {text: gettext('Download')} template: {text: gettext('Download')}
}, },
{
service: editService,
template: {text: gettext('Edit')}
},
{ {
service: viewService, service: viewService,
template: {text: gettext('View Details')} template: {text: gettext('View Details')}
@ -75,13 +79,19 @@
function downloadService($qExtensions, $window) { function downloadService($qExtensions, $window) {
return { return {
allowed: function allowed(file) { return $qExtensions.booleanAsPromise(file.is_object); }, allowed: allowed,
perform: perform
};
function allowed(file) {
return $qExtensions.booleanAsPromise(file.is_object);
}
// remove leading url slash to ensure uses relative link/base path // remove leading url slash to ensure uses relative link/base path
// thus using webroot. // thus using webroot.
perform: function perform(file) { function perform(file) {
$window.location.href = file.url.replace(/^\//, ''); $window.location.href = file.url.replace(/^\//, '');
} }
};
} }
viewService.$inject = [ viewService.$inject = [
@ -94,10 +104,15 @@
function viewService(swiftAPI, basePath, model, $qExtensions, $modal) { function viewService(swiftAPI, basePath, model, $qExtensions, $modal) {
return { return {
allowed: function allowed(file) { allowed: allowed,
perform: perform
};
function allowed(file) {
return $qExtensions.booleanAsPromise(file.is_object); return $qExtensions.booleanAsPromise(file.is_object);
}, }
perform: function perform(file) {
function perform(file) {
var objectPromise = swiftAPI.getObjectDetails( var objectPromise = swiftAPI.getObjectDetails(
model.container.name, model.container.name,
model.fullPath(file.name) model.fullPath(file.name)
@ -117,7 +132,71 @@
$modal.open(localSpec); $modal.open(localSpec);
} }
}
editService.$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 editService(swiftAPI, basePath, model, $qExtensions, modalWaitSpinnerService,
toastService, $modal) {
return {
allowed: allowed,
perform: perform
}; };
function allowed(file) {
return $qExtensions.booleanAsPromise(file.is_object);
}
function perform(file) {
var localSpec = {
backdrop: 'static',
controller: 'horizon.dashboard.project.containers.EditObjectModalController as ctrl',
templateUrl: basePath + 'edit-object-modal.html',
resolve: {
fileDetails: function fileDetails() {
return {
path: file.path,
container: model.container.name
};
}
}
};
return $modal.open(localSpec).result.then(editObjectCallback);
}
function editObjectCallback(uploadInfo) {
modalWaitSpinnerService.showModalSpinner(gettext("Uploading"));
swiftAPI.uploadObject(
model.container.name,
uploadInfo.path,
uploadInfo.edit_file
).then(success, error);
function success() {
modalWaitSpinnerService.hideModalSpinner();
toastService.add(
'success',
interpolate(gettext('File %(path)s uploaded.'), uploadInfo, true)
);
model.updateContainer();
model.selectContainer(
model.container.name,
model.folder
);
}
function error() {
modalWaitSpinnerService.hideModalSpinner();
}
}
} }
deleteService.$inject = [ deleteService.$inject = [
@ -129,10 +208,15 @@
function deleteService(basePath, actionResultService, $qExtensions, $modal) { function deleteService(basePath, actionResultService, $qExtensions, $modal) {
return { return {
allowed: function allowed() { allowed: allowed,
perform: perform
};
function allowed() {
return $qExtensions.booleanAsPromise(true); return $qExtensions.booleanAsPromise(true);
}, }
perform: function perform(file) {
function perform(file) {
var localSpec = { var localSpec = {
backdrop: 'static', backdrop: 'static',
controller: 'DeleteObjectsModalController as ctrl', controller: 'DeleteObjectsModalController as ctrl',
@ -150,6 +234,5 @@
).result; ).result;
}); });
} }
};
} }
})(); })();

View File

@ -41,7 +41,7 @@
it('should create an actions list', function test() { it('should create an actions list', function test() {
expect(rowActions.actions).toBeDefined(); expect(rowActions.actions).toBeDefined();
var actions = rowActions.actions(); var actions = rowActions.actions();
expect(actions.length).toEqual(3); expect(actions.length).toEqual(4);
angular.forEach(actions, function check(action) { angular.forEach(actions, function check(action) {
expect(action.service).toBeDefined(); expect(action.service).toBeDefined();
expect(action.template).toBeDefined(); expect(action.template).toBeDefined();
@ -171,6 +171,97 @@
}); });
}); });
describe('editService', function test() {
var swiftAPI, editService, modalWaitSpinnerService, toastService, $q;
beforeEach(inject(function inject($injector, _$q_) {
swiftAPI = $injector.get('horizon.app.core.openstack-service-api.swift');
editService = $injector.get('horizon.dashboard.project.containers.objects-actions.edit');
modalWaitSpinnerService = $injector.get(
'horizon.framework.widgets.modal-wait-spinner.service'
);
toastService = $injector.get('horizon.framework.widgets.toast.service');
$q = _$q_;
}));
it('should have an allowed and perform', function test() {
expect(editService.allowed).toBeDefined();
expect(editService.perform).toBeDefined();
});
it('should only allow files', function test() {
expectAllowed(editService.allowed({is_object: true}));
});
it('should only now allow folders', function test() {
expectNotAllowed(editService.allowed({is_object: false}));
});
it('should handle upload success correctly', function() {
var modalDeferred = $q.defer();
var apiDeferred = $q.defer();
var result = { result: modalDeferred.promise };
spyOn($modal, 'open').and.returnValue(result);
spyOn(modalWaitSpinnerService, 'showModalSpinner');
spyOn(modalWaitSpinnerService, 'hideModalSpinner');
spyOn(swiftAPI, 'uploadObject').and.returnValue(apiDeferred.promise);
spyOn(toastService, 'add').and.callThrough();
spyOn(model,'updateContainer');
spyOn(model,'selectContainer');
editService.perform();
model.container = {name: 'spam'};
$rootScope.$apply();
// Close the modal, make sure API call succeeds
modalDeferred.resolve({name: 'ham', path: '/folder/ham'});
apiDeferred.resolve();
$rootScope.$apply();
// Check the string of functions called by this code path succeed
expect($modal.open).toHaveBeenCalled();
expect(modalWaitSpinnerService.showModalSpinner).toHaveBeenCalled();
expect(swiftAPI.uploadObject).toHaveBeenCalled();
expect(toastService.add).toHaveBeenCalledWith('success', 'File /folder/ham uploaded.');
expect(modalWaitSpinnerService.hideModalSpinner).toHaveBeenCalled();
expect(model.updateContainer).toHaveBeenCalled();
expect(model.selectContainer).toHaveBeenCalled();
});
it('should handle upload error correctly', function() {
var modalDeferred = $q.defer();
var apiDeferred = $q.defer();
var result = { result: modalDeferred.promise };
spyOn($modal, 'open').and.returnValue(result);
spyOn(modalWaitSpinnerService, 'showModalSpinner');
spyOn(modalWaitSpinnerService, 'hideModalSpinner');
spyOn(swiftAPI, 'uploadObject').and.returnValue(apiDeferred.promise);
spyOn(toastService, 'add').and.callThrough();
spyOn(model,'updateContainer');
spyOn(model,'selectContainer');
editService.perform();
model.container = {name: 'spam'};
$rootScope.$apply();
// Close the modal, make sure API call is rejected
modalDeferred.resolve({name: 'ham', path: '/'});
apiDeferred.reject();
$rootScope.$apply();
// Check the string of functions called by this code path succeed
expect(modalWaitSpinnerService.showModalSpinner).toHaveBeenCalled();
expect(swiftAPI.uploadObject).toHaveBeenCalled();
expect(modalWaitSpinnerService.hideModalSpinner).toHaveBeenCalled();
expect($modal.open).toHaveBeenCalled();
// Check the success branch is not called
expect(model.updateContainer).not.toHaveBeenCalled();
expect(model.selectContainer).not.toHaveBeenCalled();
expect(toastService.add).not.toHaveBeenCalledWith('success');
});
});
function exerciseAllowedPromise(promise) { function exerciseAllowedPromise(promise) {
var handler = jasmine.createSpyObj('handler', ['success', 'error']); var handler = jasmine.createSpyObj('handler', ['success', 'error']);
promise.then(handler.success, handler.error); promise.then(handler.success, handler.error);

View File

@ -91,5 +91,44 @@
expect(model.selectContainer).toHaveBeenCalledWith('spam', 'ham'); expect(model.selectContainer).toHaveBeenCalledWith('spam', 'ham');
}); });
it('should handle action results when result is undefined', function test() {
var ctrl = createController();
spyOn(model, 'updateContainer');
spyOn($scope, '$broadcast');
ctrl.actionResultHandler();
expect($scope.$broadcast).not.toHaveBeenCalled();
expect(model.updateContainer).not.toHaveBeenCalled();
expect(model.selectContainer).not.toHaveBeenCalled();
});
it('should handle action results with an empty deleted list', function test() {
var ctrl = createController();
var result = { deleted: [] };
spyOn(model, 'updateContainer');
spyOn($scope, '$broadcast');
ctrl.actionResultHandler(result);
expect($scope.$broadcast).not.toHaveBeenCalled();
expect(model.updateContainer).not.toHaveBeenCalled();
expect(model.selectContainer).not.toHaveBeenCalled();
});
it('should handle action results', function test() {
var ctrl = createController();
spyOn($scope, '$broadcast');
spyOn(model, 'updateContainer');
var d = $q.defer();
ctrl.actionResultHandler(d.promise);
d.resolve({deleted: [1]});
$scope.$apply();
expect(model.updateContainer).toHaveBeenCalled();
expect(model.selectContainer).toHaveBeenCalledWith('spam', undefined);
});
}); });
})(); })();

View File

@ -18,24 +18,27 @@
angular angular
.module('horizon.dashboard.project.containers') .module('horizon.dashboard.project.containers')
.controller('UploadObjectModalController', UploadObjectModalController); .controller(
'horizon.dashboard.project.containers.UploadObjectModalController',
UploadObjectModalController
);
UploadObjectModalController.$inject = [ UploadObjectModalController.$inject = [
'horizon.dashboard.project.containers.containers-model', 'horizon.dashboard.project.containers.containers-model'
'$scope'
]; ];
function UploadObjectModalController(model, $scope) { function UploadObjectModalController(model) {
var ctrl = this; var ctrl = this;
ctrl.model = { ctrl.model = {
name:'', name: '',
container: model.container, container: model.container,
folder: model.folder, folder: model.folder,
view_file: null, // file object managed by angular form ngModel view_file: null, // file object managed by angular form ngModel
upload_file: null, // file object from the DOM element with the actual upload upload_file: null, // file object from the DOM element with the actual upload
DELIMETER: model.DELIMETER DELIMETER: model.DELIMETER
}; };
ctrl.form = null; // set by the HTML
ctrl.changeFile = changeFile; ctrl.changeFile = changeFile;
/////////// ///////////
@ -45,9 +48,12 @@
// update the upload file & its name // update the upload file & its name
ctrl.model.upload_file = files[0]; ctrl.model.upload_file = files[0];
ctrl.model.name = files[0].name; ctrl.model.name = files[0].name;
ctrl.form.name.$setDirty();
// we're modifying the model value from a DOM event so we need to manually $digest // Note that a $scope.$digest() is now needed for the change to the ngModel to be
$scope.$digest(); // reflected in the page (since this callback is fired from inside a DOM event)
// but the on-file-changed directive currently does a digest after this callback
// is invoked.
} }
} }
} }

View File

@ -18,7 +18,7 @@
'use strict'; 'use strict';
describe('horizon.dashboard.project.containers upload-object controller', function() { describe('horizon.dashboard.project.containers upload-object controller', function() {
var controller, $scope; var controller, ctrl;
beforeEach(module('horizon.framework')); beforeEach(module('horizon.framework'));
beforeEach(module('horizon.dashboard.project.containers')); beforeEach(module('horizon.dashboard.project.containers'));
@ -30,42 +30,31 @@
}); });
})); }));
beforeEach(inject(function ($injector, _$rootScope_) { beforeEach(inject(function ($injector) {
controller = $injector.get('$controller'); controller = $injector.get('$controller');
$scope = _$rootScope_.$new(true); ctrl = controller('horizon.dashboard.project.containers.UploadObjectModalController');
ctrl.form = {name: {$setDirty: angular.noop}};
spyOn(ctrl.form.name, '$setDirty');
})); }));
function createController() {
return controller('UploadObjectModalController', {$scope: $scope});
}
it('should initialise the controller model when created', function test() { it('should initialise the controller model when created', function test() {
var ctrl = createController();
expect(ctrl.model.name).toEqual(''); expect(ctrl.model.name).toEqual('');
expect(ctrl.model.container.name).toEqual('spam'); expect(ctrl.model.container.name).toEqual('spam');
expect(ctrl.model.folder).toEqual('ham'); expect(ctrl.model.folder).toEqual('ham');
}); });
it('should respond to file changes correctly', function test() { it('should respond to file changes correctly', function test() {
var ctrl = createController();
spyOn($scope, '$digest');
var file = {name: 'eggs'}; var file = {name: 'eggs'};
ctrl.changeFile([file]); ctrl.changeFile([file]);
expect(ctrl.model.name).toEqual('eggs'); expect(ctrl.model.name).toEqual('eggs');
expect(ctrl.model.upload_file).toEqual(file); expect(ctrl.model.upload_file).toEqual(file);
expect($scope.$digest).toHaveBeenCalled(); expect(ctrl.form.name.$setDirty).toHaveBeenCalled();
}); });
it('should respond to file changes correctly if no files are present', function test() { it('should not respond to file changes if no files are present', function test() {
var ctrl = createController();
spyOn($scope, '$digest');
ctrl.changeFile([]); ctrl.changeFile([]);
expect(ctrl.model.name).toEqual(''); expect(ctrl.model.name).toEqual('');
expect($scope.$digest).not.toHaveBeenCalled(); expect(ctrl.form.name.$setDirty).not.toHaveBeenCalled();
}); });
}); });
})(); })();

View File

@ -8,7 +8,7 @@
</div> </div>
</div> </div>
<div ng-form="uploadForm"> <div ng-form="ctrl.form">
<div class="modal-body"> <div class="modal-body">
<div class="row"> <div class="row">
<div class="col-sm-6"> <div class="col-sm-6">
@ -20,11 +20,18 @@
ng-model="ctrl.model.view_file" on-file-change="ctrl.changeFile"> ng-model="ctrl.model.view_file" on-file-change="ctrl.changeFile">
</div> </div>
</div> </div>
<div class="form-group required"> <div class="form-group required"
ng-class="{'has-error': ctrl.form.name.$dirty && ctrl.form.name.$invalid}">
<label class="control-label required" for="id_name" translate>File Name</label> <label class="control-label required" for="id_name" translate>File Name</label>
<div> <div>
<input class="form-control" type="text" id="id_name" maxlength="255" <input class="form-control" type="text" id="id_name" maxlength="255" name="name"
ng-model="ctrl.model.name" required> ng-model="ctrl.model.name" required object-name-exists />
<span class="help-block text-info" ng-show="ctrl.form.name.$pending.exists" translate>
Checking if this name is used...
</span>
<span class="help-block text-danger" ng-show="ctrl.form.name.$error.exists" translate>
This name already exists.
</span>
</div> </div>
</div> </div>
</fieldset> </fieldset>
@ -38,7 +45,6 @@
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@ -47,7 +53,7 @@
<translate>Cancel</translate> <translate>Cancel</translate>
</button> </button>
<button class="btn btn-primary" ng-click="$close(ctrl.model)" <button class="btn btn-primary" ng-click="$close(ctrl.model)"
ng-disabled="uploadForm.$invalid"> ng-disabled="ctrl.form.$invalid">
<span class="fa fa-upload"></span> <span class="fa fa-upload"></span>
<translate>Upload File</translate> <translate>Upload File</translate>
</button> </button>

View File

@ -60,7 +60,7 @@
/** /**
* @name getObjectURL * @name getObjectURL
* @param {Object} container - A container * @param {Object} container - A container name
* @param {Object} object - The object to be encoded * @param {Object} object - The object to be encoded
* @param {string} type - String representation of the type * @param {string} type - String representation of the type
* @description * @description
@ -211,11 +211,11 @@
/** /**
* @name uploadObject * @name uploadObject
* @param {Object} container - The container * @param {Object} container - The container
* @param {string} objectName - The new object's name * @param {string} objectName - The object's name (and optional path)
* @param {Object} file - File data * @param {Object} file - File data
* @description * @description
* Add a file to the specified container with the given objectName (which * Add or replace a file in the specified container with the given objectName
* may include pseudo-folder path), the mimetype and raw file data. * (which may include pseudo-folder path), the mimetype and raw file data.
* @returns {Object} The result of the API call * @returns {Object} The result of the API call
* *
*/ */
@ -224,12 +224,8 @@
service.getObjectURL(container, objectName), service.getObjectURL(container, objectName),
{file: file} {file: file}
) )
.error(function (response, status) { .error(function () {
if (status === 409) {
toastService.add('error', response);
} else {
toastService.add('error', gettext('Unable to upload the object.')); toastService.add('error', gettext('Unable to upload the object.'));
}
}); });
} }
@ -264,14 +260,21 @@
* @param {string} objectName - The name of the object to get info about * @param {string} objectName - The name of the object to get info about
* @description * @description
* Get the metadata for an object. * Get the metadata for an object.
*
* If you just wish to test for the existence of the object, set
* ignoreError so user-visible error isn't automatically displayed.
* @returns {Object} The result of the API call * @returns {Object} The result of the API call
* *
*/ */
function getObjectDetails(container, objectName) { function getObjectDetails(container, objectName, ignoreError) {
return apiService.get( var promise = apiService.get(
service.getObjectURL(container, objectName, 'metadata') service.getObjectURL(container, objectName, 'metadata')
) );
.error(function () { if (ignoreError) {
// provide a noop error handler so the error is ignored
return promise.error(angular.noop);
}
return promise.error(function () {
toastService.add('error', gettext('Unable to get details of the object.')); toastService.add('error', gettext('Unable to get details of the object.'));
}); });
} }

View File

@ -178,19 +178,6 @@
expect(toastService.add).toHaveBeenCalledWith('error', message); expect(toastService.add).toHaveBeenCalledWith('error', message);
}); });
it('returns a relevant error message when uploadObject returns a 409 error', function test() {
var promise = {error: angular.noop};
spyOn(apiService, 'post').and.returnValue(promise);
spyOn(promise, 'error');
service.uploadObject('spam', 'ham');
spyOn(toastService, 'add');
var innerFunc = promise.error.calls.argsFor(0)[0];
// In the case of 409
var message = 'A object with the name "ham" already exists.';
innerFunc(message, 409);
expect(toastService.add).toHaveBeenCalledWith('error', message);
});
it('returns a relevant error message when deleteContainer returns a 409 error', it('returns a relevant error message when deleteContainer returns a 409 error',
function test() { function test() {
var promise = {error: angular.noop}; var promise = {error: angular.noop};

View File

@ -211,9 +211,7 @@ class SwiftApiTests(test.APITestCase):
headers = {'X-Object-Meta-Orig-Filename': fake_name} headers = {'X-Object-Meta-Orig-Filename': fake_name}
swift_api = self.stub_swiftclient(2) swift_api = self.stub_swiftclient()
exc = self.exceptions.swift
swift_api.head_object(container.name, obj.name).AndRaise(exc)
test_file = FakeFile() test_file = FakeFile()
swift_api.put_object(container.name, swift_api.put_object(container.name,
obj.name, obj.name,
@ -227,35 +225,11 @@ class SwiftApiTests(test.APITestCase):
obj.name, obj.name,
test_file) test_file)
def test_swift_upload_duplicate_object(self):
container = self.containers.first()
obj = self.objects.first()
fake_name = 'fake_object.jpg'
class FakeFile(object):
def __init__(self):
self.name = fake_name
self.data = obj.data
self.size = len(obj.data)
swift_api = self.stub_swiftclient()
swift_api.head_object(container.name, obj.name).AndReturn(obj)
test_file = FakeFile()
self.mox.ReplayAll()
with self.assertRaises(exceptions.AlreadyExists):
api.swift.swift_upload_object(self.request,
container.name,
obj.name,
test_file)
def test_swift_upload_object_without_file(self): def test_swift_upload_object_without_file(self):
container = self.containers.first() container = self.containers.first()
obj = self.objects.first() obj = self.objects.first()
swift_api = self.stub_swiftclient(2) swift_api = self.stub_swiftclient()
exc = self.exceptions.swift
swift_api.head_object(container.name, obj.name).AndRaise(exc)
swift_api.put_object(container.name, swift_api.put_object(container.name,
obj.name, obj.name,
None, None,