Merge "Add ngSwift object actions"
This commit is contained in:
commit
bda5c01d45
@ -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
|
||||
|
@ -0,0 +1,3 @@
|
||||
<action action-classes="'$action-classes$'" item="$item$" button-type="link">
|
||||
$text$
|
||||
</action>
|
@ -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
|
||||
* }];
|
||||
* }
|
||||
*
|
||||
|
@ -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
|
||||
});
|
||||
}
|
||||
|
7
horizon/static/framework/widgets/action-list/link.html
Normal file
7
horizon/static/framework/widgets/action-list/link.html
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
@ -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>
|
||||
|
12
horizon/static/framework/widgets/action-list/split-link.html
Normal file
12
horizon/static/framework/widgets/action-list/split-link.html
Normal 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>
|
@ -1,4 +1,4 @@
|
||||
<p>{$ ::message $}</p>
|
||||
<div>
|
||||
<a ng-click="expandDetail()">{$ ::clickMessage $}</a>
|
||||
<a ng-click="expandDetail(); $event.stopPropagation()">{$ ::clickMessage $}</a>
|
||||
</div>
|
@ -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):
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
@ -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'}]);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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>
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
})();
|
@ -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');
|
||||
});
|
||||
});
|
||||
})();
|
@ -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>
|
@ -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
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
@ -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();
|
||||
}
|
||||
});
|
||||
})();
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
})();
|
||||
|
@ -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">
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
@ -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();
|
||||
});
|
||||
});
|
||||
})();
|
@ -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>
|
@ -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;
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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.
|
Loading…
Reference in New Issue
Block a user