diff --git a/horizon/static/framework/widgets/action-list/action.directive.js b/horizon/static/framework/widgets/action-list/action.directive.js index 2e62c74059..1a980a06ca 100644 --- a/horizon/static/framework/widgets/action-list/action.directive.js +++ b/horizon/static/framework/widgets/action-list/action.directive.js @@ -37,7 +37,7 @@ * Attributes: * * actionClasses: classes added to button or link - * callback: function called when button or link clicked + * callback: function called when button clicked or link needed for rendering * disabled: disable/enable button dynamically * item: object passed to callback function * @@ -57,6 +57,10 @@ * * Delete * + * + * + * Download + * * ``` */ angular diff --git a/horizon/static/framework/widgets/action-list/actions-link.template.html b/horizon/static/framework/widgets/action-list/actions-link.template.html new file mode 100644 index 0000000000..cdf5635714 --- /dev/null +++ b/horizon/static/framework/widgets/action-list/actions-link.template.html @@ -0,0 +1,3 @@ + + $text$ + diff --git a/horizon/static/framework/widgets/action-list/actions.directive.js b/horizon/static/framework/widgets/action-list/actions.directive.js index 35fe62518f..a8ceddad74 100644 --- a/horizon/static/framework/widgets/action-list/actions.directive.js +++ b/horizon/static/framework/widgets/action-list/actions.directive.js @@ -81,6 +81,7 @@ * 2. 'danger' - For marking an Action as dangerous. Only for 'row' type. * 3. 'delete-selected' - Delete multiple rows. Only for 'batch' type. * 4. 'create' - Create a new entity. Only for 'batch' type. + * 5. 'link' - Generates a link instead of button. Only for 'row' type. * * The styling of the action button is done based on the 'listType'. * The directive will be responsible for binding the correct callback. @@ -101,6 +102,8 @@ * When using 'row' type, the current 'item' is evaluated and passed to the function. * When using 'batch' type, 'item' is not passed. * When using 'delete-selected' for 'batch' type, all selected rows are passed. + * When using 'link' this is invoked during rendering with the current 'item' passed + * and should return the URL for the link. * * @restrict E * @scope @@ -186,6 +189,15 @@ * } * }; * + * var downloadService = { + * allowed: function(image) { + * return isPublic(image); + * }, + * perform: function(image) { + * return generateUrlFor(image); + * } + * }; + * * Then create the Service to use in the HTML which lists * all allowed actions with the templates to use. * @@ -201,6 +213,12 @@ * text: gettext('Create Volume') * }, * service: createVolumeService + * }, { + * template: { + * text: gettext('Download'), + * type: 'link', + * }, + * service: downloadService * }]; * } * diff --git a/horizon/static/framework/widgets/action-list/actions.service.js b/horizon/static/framework/widgets/action-list/actions.service.js index 3888624281..dd5c8f69ec 100644 --- a/horizon/static/framework/widgets/action-list/actions.service.js +++ b/horizon/static/framework/widgets/action-list/actions.service.js @@ -153,7 +153,11 @@ */ function getSplitButton(actionTemplate) { var actionElement = angular.element(actionTemplate.template); - actionElement.attr('button-type', 'split-button'); + var type = actionTemplate.type; + if (type !== 'link') { + type = 'button'; + } + actionElement.attr('button-type', 'split-' + type); actionElement.attr('action-classes', actionElement.attr('action-classes')); actionElement.attr('callback', actionTemplate.callback); return actionElement; @@ -184,8 +188,8 @@ function getTemplate(permittedAction, index, permittedActions) { var defered = $q.defer(); var action = permittedAction.context; - $http.get(getTemplateUrl(action, permittedActions.length), {cache: $templateCache}) - .then(onTemplateGet); + var url = getTemplateUrl(action, permittedActions.length); + $http.get(url, {cache: $templateCache}).then(onTemplateGet); return defered.promise; function onTemplateGet(response) { @@ -198,6 +202,7 @@ .replace('$item$', item); defered.resolve({ template: template, + type: action.template.type || 'button', callback: callback }); } diff --git a/horizon/static/framework/widgets/action-list/link.html b/horizon/static/framework/widgets/action-list/link.html new file mode 100644 index 0000000000..ae392466f9 --- /dev/null +++ b/horizon/static/framework/widgets/action-list/link.html @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/horizon/static/framework/widgets/action-list/menu-item.html b/horizon/static/framework/widgets/action-list/menu-item.html index 086a1efdbc..b6c8aa4401 100644 --- a/horizon/static/framework/widgets/action-list/menu-item.html +++ b/horizon/static/framework/widgets/action-list/menu-item.html @@ -1,8 +1,9 @@ +
  • + ng-click="disabled || callback(item); $event.stopPropagation(); $event.preventDefault()">
  • diff --git a/horizon/static/framework/widgets/action-list/single-button.html b/horizon/static/framework/widgets/action-list/single-button.html index 54145254cd..29b9714838 100644 --- a/horizon/static/framework/widgets/action-list/single-button.html +++ b/horizon/static/framework/widgets/action-list/single-button.html @@ -2,7 +2,7 @@ \ No newline at end of file diff --git a/horizon/static/framework/widgets/action-list/split-button.html b/horizon/static/framework/widgets/action-list/split-button.html index f5a42e3ad9..d877dc5b5a 100644 --- a/horizon/static/framework/widgets/action-list/split-button.html +++ b/horizon/static/framework/widgets/action-list/split-button.html @@ -2,13 +2,13 @@ diff --git a/horizon/static/framework/widgets/action-list/split-link.html b/horizon/static/framework/widgets/action-list/split-link.html new file mode 100644 index 0000000000..fc7070ceaf --- /dev/null +++ b/horizon/static/framework/widgets/action-list/split-link.html @@ -0,0 +1,12 @@ + + + + diff --git a/horizon/static/framework/widgets/action-list/warning-tooltip.html b/horizon/static/framework/widgets/action-list/warning-tooltip.html index 044fe93a0e..5ad0e1654f 100644 --- a/horizon/static/framework/widgets/action-list/warning-tooltip.html +++ b/horizon/static/framework/widgets/action-list/warning-tooltip.html @@ -1,4 +1,4 @@

    {$ ::message $}

    - {$ ::clickMessage $} + {$ ::clickMessage $}
    \ No newline at end of file diff --git a/openstack_dashboard/api/rest/swift.py b/openstack_dashboard/api/rest/swift.py index 2982c26c7d..33f12da3ad 100644 --- a/openstack_dashboard/api/rest/swift.py +++ b/openstack_dashboard/api/rest/swift.py @@ -13,10 +13,13 @@ # limitations under the License. """API for the swift service. """ +import os from django import forms +from django.http import StreamingHttpResponse from django.views.decorators.csrf import csrf_exempt from django.views import generic +import six from horizon import exceptions from openstack_dashboard import api @@ -190,6 +193,30 @@ class Object(generic.View): def delete(self, request, container, object_name): api.swift.swift_delete_object(request, container, object_name) + def get(self, request, container, object_name): + """Get the object contents. + """ + obj = api.swift.swift_get_object( + request, + container, + object_name + ) + + # Add the original file extension back on if it wasn't preserved in the + # name given to the object. + filename = object_name.rsplit(api.swift.FOLDER_DELIMITER)[-1] + if not os.path.splitext(obj.name)[1] and obj.orig_name: + name, ext = os.path.splitext(obj.orig_name) + filename = "%s%s" % (filename, ext) + response = StreamingHttpResponse(obj.data) + safe = filename.replace(",", "") + if six.PY2: + safe = safe.encode('utf-8') + response['Content-Disposition'] = 'attachment; filename="%s"' % safe + response['Content-Type'] = 'application/octet-stream' + response['Content-Length'] = obj.bytes + return response + @urls.register class ObjectMetadata(generic.View): diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/_containers.scss b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/_containers.scss index f366d9e239..2a4bd9c924 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/_containers.scss +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/_containers.scss @@ -51,6 +51,10 @@ } } +.hz-containter-title { + padding-right: .5em; +} + .hz-container-title, .hz-container-toggle { &, &:hover { @@ -72,6 +76,10 @@ border: none; } +.hz-objects.table td { + cursor: pointer; +} + .hz-object-path { margin-bottom: 0; padding-left: 0; diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers-model.service.js b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers-model.service.js index c93aab134f..63a300c70c 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers-model.service.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers-model.service.js @@ -46,19 +46,26 @@ */ function ContainersModel(swiftAPI, $q) { var model = { - info: {}, - containers: [], - container: null, - objects: [], - folder: '', + info: {}, // swift installation information + containers: [], // all containers for this account + container: null, // current active container + objects: [], // current objects list (active container) + folder: '', // current folder path pseudo_folder_hierarchy: [], DELIMETER: '/', // TODO where is this configured in the current panel initialize: initialize, selectContainer: selectContainer, - fetchContainerDetail: fetchContainerDetail + fullPath: fullPath, + fetchContainerDetail: fetchContainerDetail, + deleteObject: deleteObject, + updateContainer: updateContainer }; + // keep a handle on this promise so that controllers can resolve on the + // initialisation completing (i.e. containers listing loaded) + model.intialiseDeferred = $q.defer(); + return model; /** @@ -70,7 +77,7 @@ * Send request to get data to initialize the model. */ function initialize() { - return $q.all( + $q.all([ swiftAPI.getContainers().then(function onContainers(data) { model.containers.length = 0; push.apply(model.containers, data.data.items); @@ -78,7 +85,9 @@ swiftAPI.getInfo().then(function onInfo(data) { model.swift_info = data.info; }) - ); + ]).then(function resolve() { + model.intialiseDeferred.resolve(); + }); } /** @@ -111,12 +120,45 @@ return swiftAPI.getObjects(name, spec).then(function onObjects(response) { push.apply(model.objects, response.data.items); + // generate the download URL for each file + angular.forEach(model.objects, function setId(object) { + object.url = swiftAPI.getObjectURL(name, model.fullPath(object.name)); + }); if (folder) { push.apply(model.pseudo_folder_hierarchy, folder.split(model.DELIMETER) || [folder]); } }); } + /** + * @ngdoc method + * @name ContainersModel.fullPath + * @returns string + * + * @description + * Determine the full path name for a given file name, by prepending the + * current folder, if any. + */ + function fullPath(name) { + if (model.folder) { + return model.folder + model.DELIMETER + name; + } + return name; + } + + /** + * @ngdoc method + * @name ContainersModel.updateContainer + * @returns {promise} + * + * @description + * Update the active container using fetchContainerDetail (forced). + * + */ + function updateContainer() { + return model.fetchContainerDetail(model.container, true); + } + /** * @ngdoc method * @name ContainersModel.fetchContainerDetail @@ -131,13 +173,19 @@ function fetchContainerDetail(container, force) { // only fetch if we haven't already if (container.is_fetched && !force) { - return; + var deferred = $q.defer(); + deferred.resolve(); + return deferred.promise; } - swiftAPI.getContainer(container.name).then( + return swiftAPI.getContainer(container.name).then( function success(response) { // copy the additional detail into the container angular.extend(container, response.data); + // copy over the swift-renamed attributes + container.bytes = parseInt(container.container_bytes_used, 10); + container.count = parseInt(container.container_object_count, 10); + container.is_fetched = true; // parse the timestamp for sensible display @@ -148,5 +196,28 @@ } ); } + + /** + * @ngdoc method + * @name ContainersModel.deleteObject + * @returns {promise} + * + * @description + * Delete an object in the currently selected container. + */ + function deleteObject(object) { + var path = model.fullPath(object.name); + if (object.is_subdir) { + path += model.DELIMETER; + } + return swiftAPI.deleteObject(model.container.name, path).then( + function success() { + for (var i = model.objects.length - 1; i >= 0; i--) { + if (model.objects[i].name === object.name) { + model.objects.splice(i, 1); + } + } + }); + } } })(); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers-model.service.spec.js b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers-model.service.spec.js index 055d9a34db..c0058b4617 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers-model.service.spec.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers-model.service.spec.js @@ -144,5 +144,45 @@ expect(container.info).toEqual('yes!'); }); + + it('should update containers', function test() { + spyOn(service, 'fetchContainerDetail'); + service.container = {name: 'one'}; + service.updateContainer(); + expect(service.fetchContainerDetail).toHaveBeenCalledWith(service.container, true); + }); + + it('should delete objects', function test() { + service.container = {name: 'spam'}; + service.objects = [{name: 'one'}, {name: 'two'}]; + var deferred = $q.defer(); + spyOn(swiftAPI, 'deleteObject').and.returnValue(deferred.promise); + + service.deleteObject(service.objects[0]); + + expect(swiftAPI.deleteObject).toHaveBeenCalledWith('spam', 'one'); + + deferred.resolve(); + $rootScope.$apply(); + + expect(service.objects).toEqual([{name: 'two'}]); + }); + + it('should delete folders', function test() { + service.container = {name: 'spam'}; + service.objects = [{name: 'one', is_subdir: true}, {name: 'two'}]; + var deferred = $q.defer(); + spyOn(swiftAPI, 'deleteObject').and.returnValue(deferred.promise); + + service.deleteObject(service.objects[0]); + + // note trailing slash to indicate we're deleting the "folder" + expect(swiftAPI.deleteObject).toHaveBeenCalledWith('spam', 'one/'); + + deferred.resolve(); + $rootScope.$apply(); + + expect(service.objects).toEqual([{name: 'two'}]); + }); }); })(); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers.controller.js b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers.controller.js index a58d163dce..44d9422472 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers.controller.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers.controller.js @@ -46,10 +46,9 @@ { var ctrl = this; ctrl.model = containersModel; - containersModel.initialize(); + ctrl.model.initialize(); ctrl.baseRoute = baseRoute; ctrl.containerRoute = containerRoute; - ctrl.selectedContainer = ''; ctrl.toggleAccess = toggleAccess; ctrl.deleteContainer = deleteContainer; @@ -61,9 +60,9 @@ ////////// function selectContainer(container) { - ctrl.model.fetchContainerDetail(container); - ctrl.selectedContainer = container.name; + ctrl.model.container = container; $location.path(ctrl.containerRoute + container.name); + return ctrl.model.fetchContainerDetail(container); } function toggleAccess(container) { @@ -118,7 +117,7 @@ } // route back to no selected container if we deleted the current one - if (ctrl.selectedContainer === container.name) { + if (ctrl.model.container.name === container.name) { $location.path(ctrl.baseRoute); } }); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers.controller.spec.js b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers.controller.spec.js index 5fc3b62996..27251fbd34 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers.controller.spec.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers.controller.spec.js @@ -82,7 +82,7 @@ var ctrl = createController(); ctrl.selectContainer({name: 'and spam'}); expect($location.path).toHaveBeenCalledWith('eggs and spam'); - expect(ctrl.selectedContainer).toEqual('and spam'); + expect(ctrl.model.container.name).toEqual('and spam'); expect(fakeModel.fetchContainerDetail).toHaveBeenCalledWith({name: 'and spam'}); }); @@ -150,7 +150,7 @@ spyOn($location, 'path'); var ctrl = createController(); - ctrl.selectedContainer = 'one'; + ctrl.model.container = {name: 'one'}; createController().deleteContainerAction(fakeModel.containers[1]); deferred.resolve(); @@ -170,7 +170,7 @@ spyOn($location, 'path'); var ctrl = createController(); - ctrl.selectedContainer = 'two'; + ctrl.model.container = {name: 'two'}; ctrl.deleteContainerAction(fakeModel.containers[1]); deferred.resolve(); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers.html b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers.html index 86aca1d9d7..e406f6998a 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers.html +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/containers.html @@ -12,8 +12,9 @@
    - +
    @@ -50,7 +51,7 @@
    diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/file-change-directive.js b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/file-change-directive.js new file mode 100644 index 0000000000..67318716a8 --- /dev/null +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/file-change-directive.js @@ -0,0 +1,44 @@ +/* + * (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') + .directive('onFileChange', OnFileChange); + + OnFileChange.$inject = []; + + function OnFileChange() { + return { + restrict: 'A', + require: 'ngModel', + link: function link(scope, element, attrs, ngModel) { + var onFileChangeHandler = scope.$eval(attrs.onFileChange); + element.on('change', function change(event) { + onFileChangeHandler(event.target.files); + // we need to manually change the view element and force a render + // to have angular pick up that the file upload now has a value + // and any required constraint is now satisfied + scope.$apply(function expression() { + ngModel.$setViewValue(event.target.files[0].name); + ngModel.$render(); + }); + }); + } + }; + } +})(); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/file-change-directive.spec.js b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/file-change-directive.spec.js new file mode 100644 index 0000000000..6349a14c80 --- /dev/null +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/file-change-directive.spec.js @@ -0,0 +1,54 @@ +/* + * (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.dashboard.project.containers')); + + var $compile, $scope; + + beforeEach(inject(function inject($injector, _$rootScope_) { + $scope = _$rootScope_.$new(); + $compile = $injector.get('$compile'); + })); + + it('should detect changes to file selection and update things', function test() { + // set up scope for the elements below + $scope.model = ''; + $scope.changed = angular.noop; + spyOn($scope, 'changed'); + + var element = angular.element( + '
    ' + + '{{ model }}
    ' + ); + element = $compile(element)($scope); + $scope.$apply(); + + // generate a file change event with a "file" selected + var files = [{name: 'test.txt', size: 1}]; + element.find('input').triggerHandler({ + type: 'change', + target: {files: files} + }); + + expect($scope.changed).toHaveBeenCalled(); + expect($scope.model).toEqual('test.txt'); + expect(element.find('span').text()).toEqual('test.txt'); + }); + }); +})(); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/object-details-modal.html b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/object-details-modal.html new file mode 100644 index 0000000000..a26f013aae --- /dev/null +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/object-details-modal.html @@ -0,0 +1,28 @@ + \ No newline at end of file diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects-row-actions.service.js b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects-row-actions.service.js new file mode 100644 index 0000000000..754ade9410 --- /dev/null +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects-row-actions.service.js @@ -0,0 +1,158 @@ +/* + * (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-row-actions', rowActions) + .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.view', viewService); + + rowActions.$inject = [ + 'horizon.dashboard.project.containers.basePath', + 'horizon.dashboard.project.containers.objects-actions.delete', + 'horizon.dashboard.project.containers.objects-actions.download', + 'horizon.dashboard.project.containers.objects-actions.view', + 'horizon.framework.util.i18n.gettext' + ]; + + /** + * @ngdoc factory + * @name horizon.app.core.images.table.row-actions.service + * @description A list of row actions. + */ + function rowActions( + basePath, + deleteService, + downloadService, + viewService, + gettext + ) { + return { + actions: actions + }; + + /////////////// + + function actions() { + return [ + { + service: downloadService, + template: {text: gettext('Download'), type: 'link'} + }, + { + service: viewService, + template: {text: gettext('View Details')} + }, + { + service: deleteService, + template: {text: gettext('Delete'), type: 'delete'} + } + ]; + } + } + + downloadService.$inject = [ + 'horizon.framework.util.q.extensions' + ]; + + function downloadService($qExtensions) { + return { + allowed: function allowed(file) { return $qExtensions.booleanAsPromise(file.is_object); }, + perform: function perform(file) { return file.url; } + }; + } + + viewService.$inject = [ + 'horizon.app.core.openstack-service-api.swift', + 'horizon.dashboard.project.containers.basePath', + 'horizon.dashboard.project.containers.containers-model', + 'horizon.framework.util.q.extensions', + '$modal' + ]; + + function viewService(swiftAPI, basePath, model, $qExtensions, $modal) { + return { + allowed: function allowed(file) { + return $qExtensions.booleanAsPromise(file.is_object); + }, + perform: function perform(file) { + var objectPromise = swiftAPI.getObjectDetails( + model.container.name, + model.fullPath(file.name) + ).then( + function received(response) { + return response.data; + } + ); + var localSpec = { + backdrop: 'static', + controller: 'SimpleModalController as ctrl', + templateUrl: basePath + 'object-details-modal.html', + resolve: { + context: function context() { return objectPromise; } + } + }; + + $modal.open(localSpec); + } + }; + } + + deleteService.$inject = [ + 'horizon.dashboard.project.containers.containers-model', + 'horizon.framework.util.q.extensions', + 'horizon.framework.widgets.modal.simple-modal.service', + 'horizon.framework.widgets.toast.service' + ]; + + function deleteService(model, $qExtensions, simpleModalService, toastService) { + var service = { + allowed: function allowed() { + return $qExtensions.booleanAsPromise(true); + }, + perform: function perform(file) { + var options = { + title: gettext('Confirm Delete'), + body: interpolate( + gettext('Are you sure you want to delete %(name)s?'), file, true + ), + submit: gettext('Yes'), + cancel: gettext('No') + }; + + simpleModalService.modal(options).result.then(function confirmed() { + return service.deleteServiceAction(file); + }); + }, + deleteServiceAction: deleteServiceAction + }; + + return service; + + function deleteServiceAction(file) { + return model.deleteObject(file).then(function success() { + model.updateContainer(); + return toastService.add('success', interpolate( + gettext('%(name)s deleted.'), {name: file.name}, true + )); + }); + } + } +})(); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects-row-actions.service.spec.js b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects-row-actions.service.spec.js new file mode 100644 index 0000000000..f29eef9798 --- /dev/null +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects-row-actions.service.spec.js @@ -0,0 +1,205 @@ +/** + * (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 row actions', function test() { + beforeEach(module('horizon.app.core.openstack-service-api')); + beforeEach(module('horizon.framework')); + beforeEach(module('horizon.dashboard.project')); + beforeEach(module(function before($provide) { + $provide.constant('horizon.dashboard.project.containers.basePath', '/base/path/'); + })); + + var rowActions, $rootScope, model; + + beforeEach(inject(function inject($injector, _$rootScope_) { + rowActions = $injector.get('horizon.dashboard.project.containers.objects-row-actions'); + model = $injector.get('horizon.dashboard.project.containers.containers-model'); + $rootScope = _$rootScope_; + })); + + it('should create an actions list', function test() { + expect(rowActions.actions).toBeDefined(); + var actions = rowActions.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('downloadService', function test() { + var downloadService; + + beforeEach(inject(function inject($injector) { + downloadService = $injector.get( + 'horizon.dashboard.project.containers.objects-actions.download' + ); + })); + + it('should have an allowed and perform', function test() { + expect(downloadService.allowed).toBeDefined(); + expect(downloadService.perform).toBeDefined(); + }); + + it('should only allow files', function test() { + expectAllowed(downloadService.allowed({is_object: true})); + }); + + it('should only now allow folders', function test() { + expectNotAllowed(downloadService.allowed({is_object: false})); + }); + + it('should immediately return a URL from perform()', function test() { + expect(downloadService.perform({url: 'spam'})).toEqual('spam'); + }); + }); + + describe('viewService', function test() { + var swiftAPI, viewService, $modal, $q; + + beforeEach(inject(function inject($injector, _$modal_, _$q_) { + swiftAPI = $injector.get('horizon.app.core.openstack-service-api.swift'); + viewService = $injector.get('horizon.dashboard.project.containers.objects-actions.view'); + $modal = _$modal_; + $q = _$q_; + })); + + it('should have an allowed and perform', function test() { + expect(viewService.allowed).toBeDefined(); + expect(viewService.perform).toBeDefined(); + }); + + it('should only allow files', function test() { + expectAllowed(viewService.allowed({is_object: true})); + }); + + it('should only now allow folders', function test() { + expectNotAllowed(viewService.allowed({is_object: false})); + }); + + it('should open a dialog on perform()', function test() { + spyOn($modal, 'open'); + var deferred = $q.defer(); + spyOn(swiftAPI, 'getObjectDetails').and.returnValue(deferred.promise); + model.container = {name: 'spam'}; + + viewService.perform({name: 'ham'}); + + deferred.resolve({data: { + name: 'name', + hash: 'hash', + content_type: 'content/type', + timestamp: 'timestamp', + last_modified: 'last_modified', + bytes: 'bytes' + }}); + $rootScope.$apply(); + + 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/object-details-modal.html'); + expect(swiftAPI.getObjectDetails).toHaveBeenCalledWith('spam', 'ham'); + }); + }); + + describe('deleteService', function test() { + var deleteService, simpleModal, toast, $q; + + beforeEach(inject(function inject($injector, _$q_) { + deleteService = $injector.get( + 'horizon.dashboard.project.containers.objects-actions.delete' + ); + simpleModal = $injector.get('horizon.framework.widgets.modal.simple-modal.service'); + toast = $injector.get('horizon.framework.widgets.toast.service'); + $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 open a dialog on perform()', function test() { + // deferred to be resolved then the modal is "closed" in a bit + var deferred = $q.defer(); + var result = { result: deferred.promise }; + spyOn(simpleModal, 'modal').and.returnValue(result); + spyOn(deleteService, 'deleteServiceAction'); + + deleteService.perform({name: 'ham'}); + $rootScope.$apply(); + + expect(simpleModal.modal).toHaveBeenCalled(); + var spec = simpleModal.modal.calls.mostRecent().args[0]; + expect(spec.title).toBeDefined(); + expect(spec.body).toEqual('Are you sure you want to delete ham?'); + expect(spec.submit).toBeDefined(); + expect(spec.cancel).toBeDefined(); + + // "close" the modal, make sure delete is called + deferred.resolve(); + $rootScope.$apply(); + expect(deleteService.deleteServiceAction).toHaveBeenCalledWith({name: 'ham'}); + }); + + it('should delete objects', function test() { + var deferred = $q.defer(); + spyOn(model, 'deleteObject').and.returnValue(deferred.promise); + spyOn(model, 'updateContainer'); + spyOn(toast, 'add'); + + deleteService.deleteServiceAction({name: 'one', is_object: true}); + + expect(model.deleteObject).toHaveBeenCalledWith({name: 'one', is_object: true}); + expect(model.deleteObject.calls.count()).toEqual(1); + + deferred.resolve(); + $rootScope.$apply(); + expect(toast.add).toHaveBeenCalledWith('success', 'one deleted.'); + expect(model.updateContainer).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(); + } + + function expectNotAllowed(promise) { + var handler = exerciseAllowedPromise(promise); + expect(handler.success).not.toHaveBeenCalled(); + expect(handler.error).toHaveBeenCalled(); + } + }); +})(); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects.controller.js b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects.controller.js index a629f23d84..c75a9a60e0 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects.controller.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects.controller.js @@ -30,23 +30,225 @@ .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-row-actions', + 'horizon.framework.widgets.modal-wait-spinner.service', + 'horizon.framework.widgets.modal.simple-modal.service', + 'horizon.framework.widgets.toast.service', + '$modal', + '$q', '$routeParams' ]; - function ObjectsController(containersModel, containerRoute, $routeParams) { + function ObjectsController(swiftAPI, containersModel, containerRoute, basePath, rowActions, + modalWaitSpinnerService, simpleModalService, toastService, + $modal, $q, $routeParams) + { var ctrl = this; + ctrl.rowActions = rowActions; ctrl.model = containersModel; + ctrl.selected = {}; + ctrl.numSelected = 0; - ctrl.containerURL = containerRoute + $routeParams.container + '/'; + ctrl.containerURL = containerRoute + encodeURIComponent($routeParams.container) + + ctrl.model.DELIMETER; if (angular.isDefined($routeParams.folder)) { - ctrl.currentURL = ctrl.containerURL + $routeParams.folder + '/'; + ctrl.currentURL = ctrl.containerURL + encodeURIComponent($routeParams.folder) + + ctrl.model.DELIMETER; } else { ctrl.currentURL = ctrl.containerURL; } - ctrl.model.selectContainer($routeParams.container, $routeParams.folder); + ctrl.breadcrumbs = []; + + // ensure that the base model data is loaded and then run our path-based + // container selection + ctrl.model.intialiseDeferred.promise.then(function afterInitialise() { + ctrl.model.selectContainer($routeParams.container, $routeParams.folder) + .then(function then() { + ctrl.breadcrumbs = ctrl.getBreadcrumbs(); + }); + }); + + ctrl.anySelectable = anySelectable; + ctrl.isSelected = isSelected; + ctrl.selectAll = selectAll; + ctrl.clearSelected = clearSelected; + ctrl.toggleSelect = toggleSelect; + ctrl.deleteSelected = deleteSelected; + ctrl.deleteSelectedAction = deleteSelectedAction; + ctrl.createFolder = createFolder; + ctrl.createFolderCallback = createFolderCallback; + ctrl.getBreadcrumbs = getBreadcrumbs; + ctrl.objectURL = objectURL; + ctrl.uploadObject = uploadObject; + ctrl.uploadObjectCallback = uploadObjectCallback; + + ////////// + + function anySelectable() { + for (var i = 0; i < ctrl.model.objects.length; i++) { + if (ctrl.model.objects[i].is_object) { + return true; + } + } + return false; + } + + function isSelected(file) { + if (!file.is_object) { + return false; + } + var state = ctrl.selected[file.name]; + return angular.isDefined(state) && state.checked; + } + + function selectAll() { + ctrl.clearSelected(); + angular.forEach(ctrl.model.objects, function each(file) { + if (file.is_object) { + ctrl.selected[file.name] = {checked: true, file: file}; + ctrl.numSelected++; + } + }); + } + + function clearSelected() { + ctrl.selected = {}; + ctrl.numSelected = 0; + } + + function toggleSelect(file) { + if (!file.is_object) { + return; + } + 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); + for (var i = 0; i < encoded.length; i++) { + crumbs.push({ + label: ctrl.model.pseudo_folder_hierarchy[i], + url: ctrl.containerURL + encoded.slice(0, i + 1).join(ctrl.model.DELIMETER) + }); + } + return crumbs; + } + + function objectURL(file) { + return ctrl.currentURL + encodeURIComponent(file.name); + } + + function deleteSelected() { + var options = { + title: gettext('Confirm Delete'), + body: interpolate( + gettext('Are you sure you want to delete %(numSelected)s files?'), + ctrl, true + ), + submit: gettext('Yes'), + cancel: gettext('No') + }; + simpleModalService.modal(options).result.then(function confirmed() { + return ctrl.deleteSelectedAction(); + }); + } + + function deleteSelectedAction() { + var promises = []; + angular.forEach(ctrl.selected, function deleteObject(item) { + promises.push(ctrl.model.deleteObject(item.file)); + }); + modalWaitSpinnerService.showModalSpinner(gettext("Deleting")); + function clean() { + modalWaitSpinnerService.hideModalSpinner(); + ctrl.clearSelected(); + ctrl.model.updateContainer(); + } + $q.all(promises).then(function success() { + clean(); + toastService.add('success', gettext('Deleted.')); + }, function fail() { + clean(); + toastService.add('error', gettext('Failed to delete.')); + }); + } + + 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(); + }); + } } })(); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects.controller.spec.js b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects.controller.spec.js index 9cdd64f74a..d479f42e84 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects.controller.spec.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects.controller.spec.js @@ -18,48 +18,317 @@ 'use strict'; describe('horizon.dashboard.project.containers objects controller', function() { - var $routeParams, controller, model; - beforeEach(module('horizon.framework')); beforeEach(module('horizon.app.core.openstack-service-api')); - - beforeEach(module('horizon.dashboard.project.containers', function before($provide) { + beforeEach(module('horizon.dashboard.project.containers')); + beforeEach(module(function before($provide) { $routeParams = {}; $provide.value('$routeParams', $routeParams); + $provide.constant('horizon.dashboard.project.containers.basePath', '/base/path/'); + $provide.constant('horizon.dashboard.project.containers.containerRoute', 'eggs/'); })); - beforeEach(inject(function ($injector) { + var $modal, $q, $scope, $routeParams, controller, modalWaitSpinnerService, model, + simpleModal, swiftAPI, toast; + + 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'); + simpleModal = $injector.get('horizon.framework.widgets.modal.simple-modal.service'); + 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() { - return controller('horizon.dashboard.project.containers.ObjectsController', { - 'horizon.dashboard.project.containers.containerRoute': 'eggs/' - }); + function createController(folder) { + // this is embedding a bit of knowledge of model but on the other hand + // we're not testing model in this file, so it's OK + model.container = {name: 'spam'}; + $routeParams.container = 'spam'; + model.folder = $routeParams.folder = folder; + return controller( + 'horizon.dashboard.project.containers.ObjectsController', + {$scope: $scope} + ); } it('should load contents', function test () { - spyOn(model, 'selectContainer'); - $routeParams.container = 'spam'; var ctrl = createController(); expect(ctrl.containerURL).toEqual('eggs/spam/'); expect(ctrl.currentURL).toEqual('eggs/spam/'); + model.intialiseDeferred.resolve(); + $scope.$apply(); + expect(model.selectContainer).toHaveBeenCalledWith('spam', undefined); }); - it('should handle subfolders', function test () { - spyOn(model, 'selectContainer'); - $routeParams.container = 'spam'; - $routeParams.folder = 'ham'; + it('should generate breadcrumb URLs', function test() { var ctrl = createController(); + model.pseudo_folder_hierarchy = ['foo', 'b#r']; + expect(ctrl.getBreadcrumbs()).toEqual([ + {label: 'foo', url: 'eggs/spam/foo'}, + {label: 'b#r', url: 'eggs/spam/foo/b%23r'} + ]); + }); + + it('should handle subfolders', function test() { + var ctrl = createController('ham'); expect(ctrl.containerURL).toEqual('eggs/spam/'); expect(ctrl.currentURL).toEqual('eggs/spam/ham/'); + model.intialiseDeferred.resolve(); + $scope.$apply(); + expect(model.selectContainer).toHaveBeenCalledWith('spam', 'ham'); }); + + it('should determine "any" selectability', function test() { + var ctrl = createController(); + ctrl.model.objects = [{is_object: false}, {is_object: true}]; + + 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 "any" selectability with folders', function test() { + var ctrl = createController(); + ctrl.model.objects = [{is_object: false}, {is_object: false}]; + + 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', is_object: true})).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 determine whether files are selected if folder', function test() { + // because we can have files and folders with the exact same name ... + var ctrl = createController(); + ctrl.selected = {one: {checked: true}}; + expect(ctrl.isSelected({name: 'one', is_object: false})).toEqual(false); + }); + + it('should toggle selected state on', function test() { + var ctrl = createController(); + ctrl.selected = {}; + ctrl.numSelected = 0; + ctrl.toggleSelect({name: 'one', is_object: true}); + 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', is_object: true}); + expect(ctrl.selected.one.checked).toEqual(false); + expect(ctrl.numSelected).toEqual(0); + }); + + it('should not toggle selected state for folders', function test() { + var ctrl = createController(); + ctrl.selected = {one: {checked: false}}; + ctrl.numSelected = 0; + ctrl.toggleSelect({name: 'one', is_object: false}); + expect(ctrl.selected.one.checked).toEqual(false); + expect(ctrl.numSelected).toEqual(0); + }); + + it('should select all but not folders', function test() { + var ctrl = createController(); + spyOn(ctrl, 'clearSelected'); + ctrl.selected = {}; + ctrl.model.objects = [ + {name: 'one', is_object: true}, + {name: 'two', is_object: false} + ]; + ctrl.selectAll(); + expect(ctrl.clearSelected).toHaveBeenCalled(); + expect(ctrl.selected).toEqual({one: {checked: true, file: {name: 'one', is_object: true}}}); + expect(ctrl.numSelected).toEqual(1); + }); + + it('should select all but not folders', function test() { + var ctrl = createController(); + ctrl.selected = {one: true}; + ctrl.clearSelected(); + expect(ctrl.selected).toEqual({}); + expect(ctrl.numSelected).toEqual(0); + }); + + 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(simpleModal, 'modal').and.returnValue(result); + + var ctrl = createController(); + spyOn(ctrl, 'deleteSelectedAction'); + + ctrl.selected = ['one', 'two']; + ctrl.numSelected = 2; + + ctrl.deleteSelected(); + + expect(simpleModal.modal).toHaveBeenCalled(); + var spec = simpleModal.modal.calls.mostRecent().args[0]; + expect(spec.title).toBeDefined(); + expect(spec.body).toEqual('Are you sure you want to delete 2 files?'); + expect(spec.submit).toBeDefined(); + expect(spec.cancel).toBeDefined(); + + // "close" the modal, make sure delete is called + deferred.resolve(); + $scope.$apply(); + expect(ctrl.deleteSelectedAction).toHaveBeenCalled(); + }); + + it('should bulk delete objects', function test() { + var deferred = $q.defer(); + spyOn(model, 'deleteObject').and.returnValue(deferred.promise); + spyOn(model, 'updateContainer'); + + var ctrl = createController(); + ctrl.selected = [ + {file: {name: 'one', is_object: true}} + ]; + ctrl.deleteSelectedAction(); + + expect(model.deleteObject).toHaveBeenCalledWith({name: 'one', is_object: true}); + expect(model.deleteObject.calls.count()).toEqual(1); + expect(modalWaitSpinnerService.showModalSpinner).toHaveBeenCalled(); + + deferred.resolve(); + $scope.$apply(); + expect(modalWaitSpinnerService.hideModalSpinner).toHaveBeenCalled(); + expect(toast.add).toHaveBeenCalledWith('success', 'Deleted.'); + expect(model.updateContainer).toHaveBeenCalled(); + }); + + 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(); + }); + }); })(); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects.html b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects.html index 667a64ffec..f99cd795c9 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects.html +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects.html @@ -4,16 +4,15 @@ hz-table default-sort="name"> - + @@ -21,25 +20,61 @@ - + + + + +
    + + Select All + + + Clear Selection + {$ oc.numSelected $} + + + + Folder + + + + + +
    + +
    +
    + + - + - {$ file.name $} + {$ file.name $} {$ file.name $} - + {$file.bytes | bytes$} - folder + Folder - + + + diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/upload-object-controller.js b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/upload-object-controller.js new file mode 100644 index 0000000000..30be321012 --- /dev/null +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/upload-object-controller.js @@ -0,0 +1,54 @@ +/* + * (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('UploadObjectModalController', UploadObjectModalController); + + UploadObjectModalController.$inject = [ + 'horizon.dashboard.project.containers.containers-model', + '$scope' + ]; + + function UploadObjectModalController(model, $scope) { + var ctrl = this; + + ctrl.model = { + name:'', + container: model.container, + folder: model.folder, + view_file: null, // file object managed by angular form ngModel + upload_file: null, // file object from the DOM element with the actual upload + DELIMETER: model.DELIMETER + }; + ctrl.changeFile = changeFile; + + /////////// + + function changeFile(files) { + if (files.length) { + // update the upload file & its name + ctrl.model.upload_file = files[0]; + ctrl.model.name = files[0].name; + + // we're modifying the model value from a DOM event so we need to manually $digest + $scope.$digest(); + } + } + } +})(); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/upload-object-controller.spec.js b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/upload-object-controller.spec.js new file mode 100644 index 0000000000..27b4940ee7 --- /dev/null +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/upload-object-controller.spec.js @@ -0,0 +1,71 @@ +/** + * (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 upload-object controller', function() { + var controller, $scope; + + beforeEach(module('horizon.framework')); + beforeEach(module('horizon.dashboard.project.containers')); + + beforeEach(module(function ($provide) { + $provide.value('horizon.dashboard.project.containers.containers-model', { + container: {name: 'spam'}, + folder: 'ham' + }); + })); + + beforeEach(inject(function ($injector, _$rootScope_) { + controller = $injector.get('$controller'); + $scope = _$rootScope_.$new(true); + })); + + function createController() { + return controller('UploadObjectModalController', {$scope: $scope}); + } + + it('should initialise the controller model when created', function test() { + var ctrl = createController(); + expect(ctrl.model.name).toEqual(''); + expect(ctrl.model.container.name).toEqual('spam'); + expect(ctrl.model.folder).toEqual('ham'); + }); + + it('should respond to file changes correctly', function test() { + var ctrl = createController(); + spyOn($scope, '$digest'); + var file = {name: 'eggs'}; + + ctrl.changeFile([file]); + + expect(ctrl.model.name).toEqual('eggs'); + expect(ctrl.model.upload_file).toEqual(file); + expect($scope.$digest).toHaveBeenCalled(); + }); + + it('should respond to file changes correctly if no files are present', function test() { + var ctrl = createController(); + spyOn($scope, '$digest'); + + ctrl.changeFile([]); + + expect(ctrl.model.name).toEqual(''); + expect($scope.$digest).not.toHaveBeenCalled(); + }); + }); +})(); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/containers/upload-object-modal.html b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/upload-object-modal.html new file mode 100644 index 0000000000..3a837d520f --- /dev/null +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/containers/upload-object-modal.html @@ -0,0 +1,53 @@ + + +
    + + + +
    diff --git a/openstack_dashboard/static/dashboard/scss/_debt.scss b/openstack_dashboard/static/dashboard/scss/_debt.scss index 7f682fd29b..2a894b66f1 100644 --- a/openstack_dashboard/static/dashboard/scss/_debt.scss +++ b/openstack_dashboard/static/dashboard/scss/_debt.scss @@ -262,3 +262,14 @@ td .btn-group { z-index: 12000; word-wrap: break-word; } + +/* +Hack to allow a
    to be wrapped around a disabled element that +needs to have a tooltip. The disabled element won't allow a JS tooltip +to receive events, so we wrap it in another tag. For some reason a + also doesn't receive the events, but a
    does. We set +display to inline-block so that existing formatting is unaffected. +*/ +div.tooltip-hack { + display: inline-block; +} diff --git a/openstack_dashboard/themes/material/static/horizon/_icons.scss b/openstack_dashboard/themes/material/static/horizon/_icons.scss index cf5e3adc59..aeaa6d3cee 100644 --- a/openstack_dashboard/themes/material/static/horizon/_icons.scss +++ b/openstack_dashboard/themes/material/static/horizon/_icons.scss @@ -2,6 +2,7 @@ // This file does a 1-1 mapping of each font-awesome icon in use to // a corresponding Material Design Icon. +// https://materialdesignicons.com $mdi-font-path: $static_url + "/horizon/lib/mdi/fonts"; @import "/horizon/lib/mdi/scss/materialdesignicons.scss"; @@ -35,6 +36,8 @@ $icon-swap: ( exclamation-triangle: 'alert', eye: 'eye', eye-slash: 'eye-off', + folder: 'folder', + folder-o: 'folder-outline', group: 'account-multiple', home: 'home', link: 'link-variant', diff --git a/releasenotes/notes/bg-angularize-swift-9a1b44aa3646bc8c.yaml b/releasenotes/notes/bg-angularize-swift-9a1b44aa3646bc8c.yaml new file mode 100644 index 0000000000..dae8373ee9 --- /dev/null +++ b/releasenotes/notes/bg-angularize-swift-9a1b44aa3646bc8c.yaml @@ -0,0 +1,5 @@ +--- +features: + - Move OpenStack Dashboard Swift panel rendering logic to client-side + using AngularJS for significant usability improvements. Set ``DISABLED=False`` in + ``enabled/_1921_project_ng_containers_panel.py`` to enable.