From ff9ca5fe6669662706adcfc49fd231c38fea6d11 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Thu, 21 Jan 2016 12:10:36 +1100 Subject: [PATCH] Add ngSwift object actions This patch adds object actions to the angular Swift interface including viewing, download, upload etc. This patch also adds a new "link" type to the action list directives, which is used for file download links in the action buttons. Additionally some event propogation issues are corrected in the action buttons. To test set DISABLED = False in _1921_project_ng_containers_panel.py Change-Id: Iacad529d743a8a5a64028d91271b50b5b32f0182 Co-Author: Neill Cox Co-Author: Diana Whitten Partially-Implements: blueprint angularize-swift --- .../widgets/action-list/action.directive.js | 6 +- .../action-list/actions-link.template.html | 3 + .../widgets/action-list/actions.directive.js | 18 ++ .../widgets/action-list/actions.service.js | 11 +- .../framework/widgets/action-list/link.html | 7 + .../widgets/action-list/menu-item.html | 3 +- .../widgets/action-list/single-button.html | 2 +- .../widgets/action-list/split-button.html | 4 +- .../widgets/action-list/split-link.html | 12 + .../widgets/action-list/warning-tooltip.html | 2 +- openstack_dashboard/api/rest/swift.py | 27 ++ .../project/containers/_containers.scss | 8 + .../containers/containers-model.service.js | 91 +++++- .../containers-model.service.spec.js | 40 +++ .../containers/containers.controller.js | 9 +- .../containers/containers.controller.spec.js | 6 +- .../project/containers/containers.html | 9 +- .../containers/create-folder-modal.html | 47 +++ .../containers/file-change-directive.js | 44 +++ .../containers/file-change-directive.spec.js | 54 ++++ .../containers/object-details-modal.html | 28 ++ .../containers/objects-row-actions.service.js | 158 +++++++++ .../objects-row-actions.service.spec.js | 205 ++++++++++++ .../project/containers/objects.controller.js | 210 +++++++++++- .../containers/objects.controller.spec.js | 299 +++++++++++++++++- .../dashboard/project/containers/objects.html | 57 +++- .../containers/upload-object-controller.js | 54 ++++ .../upload-object-controller.spec.js | 71 +++++ .../containers/upload-object-modal.html | 53 ++++ .../static/dashboard/scss/_debt.scss | 11 + .../material/static/horizon/_icons.scss | 3 + .../bg-angularize-swift-9a1b44aa3646bc8c.yaml | 5 + 32 files changed, 1496 insertions(+), 61 deletions(-) create mode 100644 horizon/static/framework/widgets/action-list/actions-link.template.html create mode 100644 horizon/static/framework/widgets/action-list/link.html create mode 100644 horizon/static/framework/widgets/action-list/split-link.html create mode 100644 openstack_dashboard/dashboards/project/static/dashboard/project/containers/create-folder-modal.html create mode 100644 openstack_dashboard/dashboards/project/static/dashboard/project/containers/file-change-directive.js create mode 100644 openstack_dashboard/dashboards/project/static/dashboard/project/containers/file-change-directive.spec.js create mode 100644 openstack_dashboard/dashboards/project/static/dashboard/project/containers/object-details-modal.html create mode 100644 openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects-row-actions.service.js create mode 100644 openstack_dashboard/dashboards/project/static/dashboard/project/containers/objects-row-actions.service.spec.js create mode 100644 openstack_dashboard/dashboards/project/static/dashboard/project/containers/upload-object-controller.js create mode 100644 openstack_dashboard/dashboards/project/static/dashboard/project/containers/upload-object-controller.spec.js create mode 100644 openstack_dashboard/dashboards/project/static/dashboard/project/containers/upload-object-modal.html create mode 100644 releasenotes/notes/bg-angularize-swift-9a1b44aa3646bc8c.yaml 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 @@ - + + + + + + + - + - {$ 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.