Merge "Add ngSwift object actions"

This commit is contained in:
Jenkins 2016-03-15 19:40:31 +00:00 committed by Gerrit Code Review
commit bda5c01d45
32 changed files with 1496 additions and 61 deletions

View File

@ -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 @@
* <action button-type="menu-item" callback="delete" item="row">
* Delete
* </action>
*
* <action button-type="link" callback="generateUrl" item="row">
* Download
* </action>
* ```
*/
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.
* 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
* }];
* }
*

View File

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

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

View File

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

View File

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

@ -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>
<div>
<a ng-click="expandDetail()">{$ ::clickMessage $}</a>
<a ng-click="expandDetail(); $event.stopPropagation()">{$ ::clickMessage $}</a>
</div>

View File

@ -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):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.