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 <neill@ingenious.com.au>
Co-Author: Diana Whitten <hurgleburgler@gmail.com>
Partially-Implements: blueprint angularize-swift
This commit is contained in:
Richard Jones 2016-01-21 12:10:36 +11:00
parent b463ab6211
commit ff9ca5fe66
32 changed files with 1496 additions and 61 deletions

View File

@ -37,7 +37,7 @@
* Attributes: * Attributes:
* *
* actionClasses: classes added to button or link * 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 * disabled: disable/enable button dynamically
* item: object passed to callback function * item: object passed to callback function
* *
@ -57,6 +57,10 @@
* <action button-type="menu-item" callback="delete" item="row"> * <action button-type="menu-item" callback="delete" item="row">
* Delete * Delete
* </action> * </action>
*
* <action button-type="link" callback="generateUrl" item="row">
* Download
* </action>
* ``` * ```
*/ */
angular angular

View File

@ -0,0 +1,3 @@
<action action-classes="'$action-classes$'" item="$item$" button-type="link">
$text$
</action>

View File

@ -81,6 +81,7 @@
* 2. 'danger' - For marking an Action as dangerous. Only for 'row' type. * 2. 'danger' - For marking an Action as dangerous. Only for 'row' type.
* 3. 'delete-selected' - Delete multiple rows. Only for 'batch' type. * 3. 'delete-selected' - Delete multiple rows. Only for 'batch' type.
* 4. 'create' - Create a new entity. 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 styling of the action button is done based on the 'listType'.
* The directive will be responsible for binding the correct callback. * 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 'row' type, the current 'item' is evaluated and passed to the function.
* When using 'batch' type, 'item' is not passed. * When using 'batch' type, 'item' is not passed.
* When using 'delete-selected' for 'batch' type, all selected rows are 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 * @restrict E
* @scope * @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 * Then create the Service to use in the HTML which lists
* all allowed actions with the templates to use. * all allowed actions with the templates to use.
* *
@ -201,6 +213,12 @@
* text: gettext('Create Volume') * text: gettext('Create Volume')
* }, * },
* service: createVolumeService * service: createVolumeService
* }, {
* template: {
* text: gettext('Download'),
* type: 'link',
* },
* service: downloadService
* }]; * }];
* } * }
* *

View File

@ -153,7 +153,11 @@
*/ */
function getSplitButton(actionTemplate) { function getSplitButton(actionTemplate) {
var actionElement = angular.element(actionTemplate.template); 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('action-classes', actionElement.attr('action-classes'));
actionElement.attr('callback', actionTemplate.callback); actionElement.attr('callback', actionTemplate.callback);
return actionElement; return actionElement;
@ -184,8 +188,8 @@
function getTemplate(permittedAction, index, permittedActions) { function getTemplate(permittedAction, index, permittedActions) {
var defered = $q.defer(); var defered = $q.defer();
var action = permittedAction.context; var action = permittedAction.context;
$http.get(getTemplateUrl(action, permittedActions.length), {cache: $templateCache}) var url = getTemplateUrl(action, permittedActions.length);
.then(onTemplateGet); $http.get(url, {cache: $templateCache}).then(onTemplateGet);
return defered.promise; return defered.promise;
function onTemplateGet(response) { function onTemplateGet(response) {
@ -198,6 +202,7 @@
.replace('$item$', item); .replace('$item$', item);
defered.resolve({ defered.resolve({
template: template, template: template,
type: action.template.type || 'button',
callback: callback callback: callback
}); });
} }

View File

@ -0,0 +1,7 @@
<!-- link - note target attribute to avoid angular routing -->
<a tabindex="0"
class="single-button dropdown-toggle {$ disabled ? 'disabled' : '' $}"
ng-class="actionClasses" target="_self"
href="{$ callback(item) $}">
<ng-transclude></ng-transclude>
</a>

View File

@ -1,8 +1,9 @@
<!-- Dropdown menu item --> <!-- Dropdown menu item -->
<!-- note that we stop all potential handling of the click outside of this tag WITH EXTREME PREJUDICE -->
<li role="presentation" ng-class="{ disabled: disabled }"> <li role="presentation" ng-class="{ disabled: disabled }">
<a role="menuitem" href="#" <a role="menuitem" href="#"
ng-class="actionClasses" ng-class="actionClasses"
ng-click="disabled || callback(item)"> ng-click="disabled || callback(item); $event.stopPropagation(); $event.preventDefault()">
<ng-transclude></ng-transclude> <ng-transclude></ng-transclude>
</a> </a>
</li> </li>

View File

@ -2,7 +2,7 @@
<button type="button" tabindex="0" <button type="button" tabindex="0"
class="single-button dropdown-toggle {$ disabled ? 'disabled' : '' $}" class="single-button dropdown-toggle {$ disabled ? 'disabled' : '' $}"
ng-class="actionClasses" ng-class="actionClasses"
ng-click="disabled || callback(item)"> ng-click="disabled || callback(item); $event.stopPropagation()">
<ng-transclude></ng-transclude> <ng-transclude></ng-transclude>
<span class="fa fa-caret-down"></span> <span class="fa fa-caret-down"></span>
</button> </button>

View File

@ -2,13 +2,13 @@
<button type="button" tabindex="0" <button type="button" tabindex="0"
class="split-button {$ disabled ? 'disabled' : '' $}" class="split-button {$ disabled ? 'disabled' : '' $}"
ng-class="actionClasses" ng-class="actionClasses"
ng-click="disabled || callback(item)"> ng-click="disabled || callback(item); $event.stopPropagation()">
<ng-transclude></ng-transclude> <ng-transclude></ng-transclude>
</button><!-- </button><!--
Dropdown caret button Dropdown caret button
--><button class="split-caret dropdown-toggle" dropdown-toggle --><button class="split-caret dropdown-toggle" dropdown-toggle
ng-class="actionClasses" ng-class="actionClasses"
aria-expanded="false"> aria-expanded="false" ng-click="$event.stopPropagation()">
<span class="fa fa-caret-down"></span> <span class="fa fa-caret-down"></span>
<span class="sr-only" translate>Toggle Dropdown</span> <span class="sr-only" translate>Toggle Dropdown</span>
</button> </button>

View File

@ -0,0 +1,12 @@
<!-- Dropdown link - note target attribute to avoid angular routing -->
<a tabindex="0"
class="split-button {$ disabled ? 'disabled' : '' $}"
ng-class="actionClasses" target="_self"
href="{$ callback(item) $}" ng-click="$event.stopPropagation()">
<ng-transclude></ng-transclude>
</a><!-- don't put a new line here. --><button class="split-caret dropdown-toggle" dropdown-toggle
ng-class="actionClasses"
aria-expanded="false" ng-click="$event.stopPropagation()">
<span class="fa fa-caret-down"></span>
<span class="sr-only" translate>Toggle Dropdown</span>
</button>

View File

@ -1,4 +1,4 @@
<p>{$ ::message $}</p> <p>{$ ::message $}</p>
<div> <div>
<a ng-click="expandDetail()">{$ ::clickMessage $}</a> <a ng-click="expandDetail(); $event.stopPropagation()">{$ ::clickMessage $}</a>
</div> </div>

View File

@ -13,10 +13,13 @@
# limitations under the License. # limitations under the License.
"""API for the swift service. """API for the swift service.
""" """
import os
from django import forms from django import forms
from django.http import StreamingHttpResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views import generic from django.views import generic
import six
from horizon import exceptions from horizon import exceptions
from openstack_dashboard import api from openstack_dashboard import api
@ -190,6 +193,30 @@ class Object(generic.View):
def delete(self, request, container, object_name): def delete(self, request, container, object_name):
api.swift.swift_delete_object(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 @urls.register
class ObjectMetadata(generic.View): class ObjectMetadata(generic.View):

View File

@ -51,6 +51,10 @@
} }
} }
.hz-containter-title {
padding-right: .5em;
}
.hz-container-title, .hz-container-title,
.hz-container-toggle { .hz-container-toggle {
&, &:hover { &, &:hover {
@ -72,6 +76,10 @@
border: none; border: none;
} }
.hz-objects.table td {
cursor: pointer;
}
.hz-object-path { .hz-object-path {
margin-bottom: 0; margin-bottom: 0;
padding-left: 0; padding-left: 0;

View File

@ -46,19 +46,26 @@
*/ */
function ContainersModel(swiftAPI, $q) { function ContainersModel(swiftAPI, $q) {
var model = { var model = {
info: {}, info: {}, // swift installation information
containers: [], containers: [], // all containers for this account
container: null, container: null, // current active container
objects: [], objects: [], // current objects list (active container)
folder: '', folder: '', // current folder path
pseudo_folder_hierarchy: [], pseudo_folder_hierarchy: [],
DELIMETER: '/', // TODO where is this configured in the current panel DELIMETER: '/', // TODO where is this configured in the current panel
initialize: initialize, initialize: initialize,
selectContainer: selectContainer, 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; return model;
/** /**
@ -70,7 +77,7 @@
* Send request to get data to initialize the model. * Send request to get data to initialize the model.
*/ */
function initialize() { function initialize() {
return $q.all( $q.all([
swiftAPI.getContainers().then(function onContainers(data) { swiftAPI.getContainers().then(function onContainers(data) {
model.containers.length = 0; model.containers.length = 0;
push.apply(model.containers, data.data.items); push.apply(model.containers, data.data.items);
@ -78,7 +85,9 @@
swiftAPI.getInfo().then(function onInfo(data) { swiftAPI.getInfo().then(function onInfo(data) {
model.swift_info = data.info; model.swift_info = data.info;
}) })
); ]).then(function resolve() {
model.intialiseDeferred.resolve();
});
} }
/** /**
@ -111,12 +120,45 @@
return swiftAPI.getObjects(name, spec).then(function onObjects(response) { return swiftAPI.getObjects(name, spec).then(function onObjects(response) {
push.apply(model.objects, response.data.items); 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) { if (folder) {
push.apply(model.pseudo_folder_hierarchy, folder.split(model.DELIMETER) || [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 * @ngdoc method
* @name ContainersModel.fetchContainerDetail * @name ContainersModel.fetchContainerDetail
@ -131,13 +173,19 @@
function fetchContainerDetail(container, force) { function fetchContainerDetail(container, force) {
// only fetch if we haven't already // only fetch if we haven't already
if (container.is_fetched && !force) { 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) { function success(response) {
// copy the additional detail into the container // copy the additional detail into the container
angular.extend(container, response.data); 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; container.is_fetched = true;
// parse the timestamp for sensible display // 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);
}
}
});
}
} }
})(); })();

View File

@ -144,5 +144,45 @@
expect(container.info).toEqual('yes!'); 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'}]);
});
}); });
})(); })();

View File

@ -46,10 +46,9 @@
{ {
var ctrl = this; var ctrl = this;
ctrl.model = containersModel; ctrl.model = containersModel;
containersModel.initialize(); ctrl.model.initialize();
ctrl.baseRoute = baseRoute; ctrl.baseRoute = baseRoute;
ctrl.containerRoute = containerRoute; ctrl.containerRoute = containerRoute;
ctrl.selectedContainer = '';
ctrl.toggleAccess = toggleAccess; ctrl.toggleAccess = toggleAccess;
ctrl.deleteContainer = deleteContainer; ctrl.deleteContainer = deleteContainer;
@ -61,9 +60,9 @@
////////// //////////
function selectContainer(container) { function selectContainer(container) {
ctrl.model.fetchContainerDetail(container); ctrl.model.container = container;
ctrl.selectedContainer = container.name;
$location.path(ctrl.containerRoute + container.name); $location.path(ctrl.containerRoute + container.name);
return ctrl.model.fetchContainerDetail(container);
} }
function toggleAccess(container) { function toggleAccess(container) {
@ -118,7 +117,7 @@
} }
// route back to no selected container if we deleted the current one // 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); $location.path(ctrl.baseRoute);
} }
}); });

View File

@ -82,7 +82,7 @@
var ctrl = createController(); var ctrl = createController();
ctrl.selectContainer({name: 'and spam'}); ctrl.selectContainer({name: 'and spam'});
expect($location.path).toHaveBeenCalledWith('eggs 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'}); expect(fakeModel.fetchContainerDetail).toHaveBeenCalledWith({name: 'and spam'});
}); });
@ -150,7 +150,7 @@
spyOn($location, 'path'); spyOn($location, 'path');
var ctrl = createController(); var ctrl = createController();
ctrl.selectedContainer = 'one'; ctrl.model.container = {name: 'one'};
createController().deleteContainerAction(fakeModel.containers[1]); createController().deleteContainerAction(fakeModel.containers[1]);
deferred.resolve(); deferred.resolve();
@ -170,7 +170,7 @@
spyOn($location, 'path'); spyOn($location, 'path');
var ctrl = createController(); var ctrl = createController();
ctrl.selectedContainer = 'two'; ctrl.model.container = {name: 'two'};
ctrl.deleteContainerAction(fakeModel.containers[1]); ctrl.deleteContainerAction(fakeModel.containers[1]);
deferred.resolve(); deferred.resolve();

View File

@ -12,8 +12,9 @@
<div class="row"> <div class="row">
<div class="col-xs-12"> <div class="col-xs-12">
<accordion class="hz-container-accordion"> <accordion class="hz-container-accordion">
<accordion-group ng-repeat="container in cc.model.containers track by container.name" <accordion-group ng-repeat="container in cc.model.containers"
ng-class="{'panel-primary': container.name === cc.selectedContainer}"> ng-class="{'panel-primary': container.name === cc.model.container.name}"
ng-click="cc.selectContainer(container)">
<accordion-heading> <accordion-heading>
<div ng-click="cc.selectContainer(container)"> <div ng-click="cc.selectContainer(container)">
<span class="hz-container-title truncate" <span class="hz-container-title truncate"
@ -24,7 +25,7 @@
<span tooltip="{$ 'Delete Container' | translate $}" tooltip-placement="top" <span tooltip="{$ 'Delete Container' | translate $}" tooltip-placement="top"
tooltip-trigger="mouseenter" tooltip-trigger="mouseenter"
class="fa fa-trash hz-container-delete-icon" class="fa fa-trash hz-container-delete-icon"
ng-if="container.name === cc.selectedContainer" ng-if="container.name === cc.model.container.name"
ng-click="cc.deleteContainer(container); $event.stopPropagation()"></span> ng-click="cc.deleteContainer(container); $event.stopPropagation()"></span>
</div> </div>
</accordion-heading> </accordion-heading>
@ -50,7 +51,7 @@
<li class="hz-object-link row"> <li class="hz-object-link row">
<div class="themable-checkbox col-lg-7 col-md-12"> <div class="themable-checkbox col-lg-7 col-md-12">
<input type="checkbox" id="id_access" ng-model="container.is_public" <input type="checkbox" id="id_access" ng-model="container.is_public"
ng-if="container.name === cc.selectedContainer" ng-if="container.name === cc.model.container.name"
ng-click="cc.toggleAccess(container)"> ng-click="cc.toggleAccess(container)">
<label class="hz-object-label" for="id_access" translate>Public Access</label> <label class="hz-object-label" for="id_access" translate>Public Access</label>
</div> </div>

View File

@ -0,0 +1,47 @@
<div class="modal-header">
<a href="" class="close" ng-click="$dismiss()">
<span class="fa fa-times"></span>
</a>
<div class="h3 modal-title" translate>
Create Folder In: {$ ctrl.model.container.name $}
<span ng-if="ctrl.model.folder">: {$ ctrl.model.folder $}</span>
</div>
</div>
<div ng-form="uploadForm"
<div class="modal-body">
<div class="row">
<div class="col-sm-6">
<fieldset>
<div class="form-group required">
<label class="control-label required" for="id_name" translate>Folder Name</label>
<span class="help-icon" data-toggle="tooltip" data-placement="top"
title=".">
<span class="fa fa-question-circle"></span>
</span>
<div>
<input class="form-control" id="id_name" maxlength="255" autofocus required
name="name" ng-model="ctrl.model.name" type="text">
</div>
</div>
</fieldset>
</div>
<div class="col-sm-6">
<p translate>
Note: Delimiters ('{$ ctrl.model.DELIMETER $}') are allowed in the
folder name to create deep folders.
</p>
</div>
</div>
</div>
<div class="modal-footer">
<input class="btn btn-primary pull-right" type="button"
ng-disabled="uploadForm.$invalid || uploadForm.$pristine"
value="{$'Create Folder'|translate$}" ng-click="$close(ctrl.model.name)">
<a href="" ng-click="$dismiss()" class="btn btn-default secondary close" translate>
Cancel
</a>
</div>
</div>

View File

@ -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();
});
});
}
};
}
})();

View File

@ -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(
'<div><input type="file" on-file-change="changed" ng-model="model" />' +
'<span>{{ model }}</span></div>'
);
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');
});
});
})();

View File

@ -0,0 +1,28 @@
<div class="modal-content ui-draggable">
<div class="modal-header ui-draggable-handle">
<a href="" class="close" ng-click="$dismiss()">
<span class="fa fa-times"></span>
</a>
<div class="h3 modal-title" translate>Object Details</div>
</div>
<div class="modal-body">
<div class="info detail">
<dl class="dl-horizontal">
<dt translate>Name</dt>
<dd>{$ctrl.context.name$}</dd>
<dt translate>Hash</dt>
<dd>{$ctrl.context.etag$}</dd>
<dt translate>Content Type</dt>
<dd>{$ctrl.context.content_type$}</dd>
<dt translate>Timestamp</dt>
<dd>{$ctrl.context.timestamp | date:'medium'$}</dd>
<dt translate>Size</dt>
<dd>{$ctrl.context.bytes | bytes$}</dd>
</dl>
</div>
</div>
<div class="modal-footer">
<a href="" ng-click="$dismiss()"
class="btn btn-default secondary cancel close" translate>Close</a>
</div>
</div>

View File

@ -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
));
});
}
}
})();

View File

@ -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();
}
});
})();

View File

@ -30,23 +30,225 @@
.controller('horizon.dashboard.project.containers.ObjectsController', ObjectsController); .controller('horizon.dashboard.project.containers.ObjectsController', ObjectsController);
ObjectsController.$inject = [ ObjectsController.$inject = [
'horizon.app.core.openstack-service-api.swift',
'horizon.dashboard.project.containers.containers-model', 'horizon.dashboard.project.containers.containers-model',
'horizon.dashboard.project.containers.containerRoute', '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' '$routeParams'
]; ];
function ObjectsController(containersModel, containerRoute, $routeParams) { function ObjectsController(swiftAPI, containersModel, containerRoute, basePath, rowActions,
modalWaitSpinnerService, simpleModalService, toastService,
$modal, $q, $routeParams)
{
var ctrl = this; var ctrl = this;
ctrl.rowActions = rowActions;
ctrl.model = containersModel; 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)) { if (angular.isDefined($routeParams.folder)) {
ctrl.currentURL = ctrl.containerURL + $routeParams.folder + '/'; ctrl.currentURL = ctrl.containerURL + encodeURIComponent($routeParams.folder) +
ctrl.model.DELIMETER;
} else { } else {
ctrl.currentURL = ctrl.containerURL; 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();
});
}
} }
})(); })();

View File

@ -18,48 +18,317 @@
'use strict'; 'use strict';
describe('horizon.dashboard.project.containers objects controller', function() { describe('horizon.dashboard.project.containers objects controller', function() {
var $routeParams, controller, model;
beforeEach(module('horizon.framework')); beforeEach(module('horizon.framework'));
beforeEach(module('horizon.app.core.openstack-service-api')); beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(module('horizon.dashboard.project.containers'));
beforeEach(module('horizon.dashboard.project.containers', function before($provide) { beforeEach(module(function before($provide) {
$routeParams = {}; $routeParams = {};
$provide.value('$routeParams', $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'); 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'); 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() { function createController(folder) {
return controller('horizon.dashboard.project.containers.ObjectsController', { // this is embedding a bit of knowledge of model but on the other hand
'horizon.dashboard.project.containers.containerRoute': 'eggs/' // 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 () { it('should load contents', function test () {
spyOn(model, 'selectContainer');
$routeParams.container = 'spam';
var ctrl = createController(); var ctrl = createController();
expect(ctrl.containerURL).toEqual('eggs/spam/'); expect(ctrl.containerURL).toEqual('eggs/spam/');
expect(ctrl.currentURL).toEqual('eggs/spam/'); expect(ctrl.currentURL).toEqual('eggs/spam/');
model.intialiseDeferred.resolve();
$scope.$apply();
expect(model.selectContainer).toHaveBeenCalledWith('spam', undefined); expect(model.selectContainer).toHaveBeenCalledWith('spam', undefined);
}); });
it('should handle subfolders', function test () { it('should generate breadcrumb URLs', function test() {
spyOn(model, 'selectContainer');
$routeParams.container = 'spam';
$routeParams.folder = 'ham';
var ctrl = createController(); 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.containerURL).toEqual('eggs/spam/');
expect(ctrl.currentURL).toEqual('eggs/spam/ham/'); expect(ctrl.currentURL).toEqual('eggs/spam/ham/');
model.intialiseDeferred.resolve();
$scope.$apply();
expect(model.selectContainer).toHaveBeenCalledWith('spam', 'ham'); 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();
});
}); });
})(); })();

View File

@ -4,16 +4,15 @@
hz-table default-sort="name"> hz-table default-sort="name">
<thead> <thead>
<tr class="page_title table_caption"> <tr class="page_title table_caption">
<th class="table_header" colspan="4"> <th class="table_header" colspan="3">
<ol class="breadcrumb hz-object-path"> <ol class="breadcrumb hz-object-path">
<li class="h4"> <li class="h4">
<a ng-href="{$ oc.containerURL $}">{$ oc.model.container.name $}</a> <a ng-href="{$ oc.containerURL $}">{$ oc.model.container.name $}</a>
</li> </li>
<li ng-repeat="pf in oc.model.pseudo_folder_hierarchy track by $index" ng-class="{'active':$last}"> <li ng-repeat="crumb in oc.breadcrumbs track by $index" ng-class="{'active':$last}">
<span> <span>
<a ng-href="{$ oc.containerURL + oc.model.pseudo_folder_hierarchy.slice(0, $index + 1).join(oc.model.DELIMETER) $}" <a ng-href="{$ crumb.url $}" ng-if="!$last">{$ crumb.label $}</a>
ng-if="!$last">{$ pf $}</a> <span ng-if="$last">{$ crumb.label $}</span>
<span ng-if="$last">{$ pf $}</span>
</span> </span>
</li> </li>
</ol> </ol>
@ -21,25 +20,61 @@
</tr> </tr>
<tr class="table_caption"> <tr class="table_caption">
<th colspan="4" class="table_header search-header"> <th colspan="3" class="table_header search-header">
<hz-search-bar group-classes="input-group-sm" <hz-search-bar group-classes="input-group-sm"
icon-classes="fa-search" input-classes="form-control" placeholder="Filter"> icon-classes="fa-search" input-classes="form-control" placeholder="Filter">
</hz-search-bar> </hz-search-bar>
</th> </th>
</tr> </tr>
<tr class="table_caption">
<th colspan="3" class="table_header">
<div class="table_actions">
<a href="" ng-disabled="!oc.anySelectable()" ng-click="oc.selectAll()"
class="btn btn-default"translate >
Select All
</a>
<a href="" ng-click="oc.clearSelected()" class="btn btn-default"
ng-disabled="oc.numSelected == 0" translate>
Clear Selection
<span ng-if="oc.numSelected > 0" class="badge">{$ oc.numSelected $}</span>
</a>
<a href="" ng-click="oc.createFolder()" class="btn btn-default">
<span class="fa fa-plus"></span>
<translate>Folder</translate>
</a>
<a href="" ng-click="oc.uploadObject()" tooltip="{$ 'Upload File' | translate $}"
tooltip-placement="top" tooltip-trigger="mouseenter" class="btn btn-default">
<span class="fa fa-upload"></span>
</a>
<!-- extra div (span doesn't work) so the tooltip shows even when the button's disabled -->
<div class="tooltip-hack" tooltip="{$ 'Delete Selection' | translate $}"
tooltip-placement="top" tooltip-trigger="mouseenter">
<button ng-disabled="oc.numSelected === 0" class="btn btn-default btn-danger"
ng-click="oc.deleteSelected(selected)">
<span class="fa fa-trash"></span>
</button>
</div>
</div>
</th>
</tr>
</thead> </thead>
<tbody> <tbody>
<tr ng-repeat="file in displayContents track by $index"> <tr ng-repeat="file in displayContents track by $index"
ng-class="{success: oc.isSelected(file)}"
ng-click="oc.toggleSelect(file)">
<td> <td>
<a ng-if="file.is_subdir" ng-href="{$ oc.currentURL + file.name $}">{$ file.name $}</a> <a ng-if="file.is_subdir" ng-href="{$ oc.objectURL(file) $}">{$ file.name $}</a>
<span ng-if="file.is_object">{$ file.name $}</span> <span ng-if="file.is_object">{$ file.name $}</span>
</td> </td>
<td> <td class="text-right">
<span ng-if="file.is_object">{$file.bytes | bytes$}</span> <span ng-if="file.is_object">{$file.bytes | bytes$}</span>
<span ng-if="file.is_subdir"><translate>folder</translate></span> <span ng-if="file.is_subdir" translate>Folder</span>
</td> </td>
<td> <td class="actions_column">
<actions allowed="oc.rowActions.actions" type="row" item="file">
</actions>
</td> </td>
</tr> </tr>
<tr hz-no-items items="displayContents"> <tr hz-no-items items="displayContents">

View File

@ -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();
}
}
}
})();

View File

@ -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();
});
});
})();

View File

@ -0,0 +1,53 @@
<div class="modal-header">
<a href="" class="close" ng-click="$dismiss()">
<span class="fa fa-times"></span>
</a>
<div class="h3 modal-title" translate>
Upload File To: {$ ctrl.model.container.name $}
<span ng-if="ctrl.model.folder">: {$ ctrl.model.folder $}</span>
</div>
</div>
<div ng-form="uploadForm">
<div class="modal-body">
<div class="row">
<div class="col-sm-6">
<fieldset>
<div class="form-group ">
<label class="control-label required" for="id_object_file" translate>File</label>
<div>
<input id="id_object_file" type="file" name="file" required
ng-model="ctrl.model.view_file" on-file-change="ctrl.changeFile">
</div>
</div>
<div class="form-group required">
<label class="control-label required" for="id_name" translate>File Name</label>
<span class="help-icon" data-toggle="tooltip" data-placement="top">
<span class="fa fa-question-circle"></span>
</span>
<div>
<input class="form-control" type="text" id="id_name" maxlength="255"
ng-model="ctrl.model.name" required>
</div>
</div>
</fieldset>
</div>
<div class="col-sm-6">
<p translate>
Note: Delimiters ('{$ ctrl.model.DELIMETER $}') are allowed in the
file name to place the new file into a folder that will be created
when the file is uploaded (to any depth of folders).
</p>
</div>
</div>
</div>
<div class="modal-footer">
<input class="btn btn-primary pull-right" type="button"
ng-disabled="uploadForm.$invalid"
value="{$'Upload File'|translate$}" ng-click="$close(ctrl.model)">
<a href="" ng-click="$dismiss()" class="btn btn-default secondary close" translate>Cancel</a>
</div>
</div>

View File

@ -262,3 +262,14 @@ td .btn-group {
z-index: 12000; z-index: 12000;
word-wrap: break-word; word-wrap: break-word;
} }
/*
Hack to allow a <div> 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
<span> also doesn't receive the events, but a <div> does. We set
display to inline-block so that existing formatting is unaffected.
*/
div.tooltip-hack {
display: inline-block;
}

View File

@ -2,6 +2,7 @@
// This file does a 1-1 mapping of each font-awesome icon in use to // This file does a 1-1 mapping of each font-awesome icon in use to
// a corresponding Material Design Icon. // a corresponding Material Design Icon.
// https://materialdesignicons.com
$mdi-font-path: $static_url + "/horizon/lib/mdi/fonts"; $mdi-font-path: $static_url + "/horizon/lib/mdi/fonts";
@import "/horizon/lib/mdi/scss/materialdesignicons.scss"; @import "/horizon/lib/mdi/scss/materialdesignicons.scss";
@ -35,6 +36,8 @@ $icon-swap: (
exclamation-triangle: 'alert', exclamation-triangle: 'alert',
eye: 'eye', eye: 'eye',
eye-slash: 'eye-off', eye-slash: 'eye-off',
folder: 'folder',
folder-o: 'folder-outline',
group: 'account-multiple', group: 'account-multiple',
home: 'home', home: 'home',
link: 'link-variant', link: 'link-variant',

View File

@ -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.