Adds ResourceBrowser and ResourceBrowserView class
related: blueprint swift-ui-improvements Provides new API for building browser for navigation, as well as new look and feel in CSS. Applies it to container panel of the nova dashboard. Change-Id: Iecd984263bae7c3774a0630639f645cee4cffca9
This commit is contained in:
parent
dfdb8375de
commit
89d3d11cb1
@ -29,6 +29,7 @@ from horizon.api.base import url_for
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
FOLDER_DELIMITER = "/"
|
||||
|
||||
|
||||
class SwiftAuthentication(object):
|
||||
@ -94,7 +95,7 @@ def swift_get_objects(request, container_name, prefix=None, path=None,
|
||||
objects = container.get_objects(prefix=prefix,
|
||||
marker=marker,
|
||||
limit=limit + 1,
|
||||
delimiter="/",
|
||||
delimiter=FOLDER_DELIMITER,
|
||||
path=path)
|
||||
if(len(objects) > limit):
|
||||
return (objects[0:-1], True)
|
||||
@ -113,7 +114,7 @@ def swift_filter_objects(request, filter_string, container_name, prefix=None,
|
||||
objects = container.get_objects(prefix=prefix,
|
||||
marker=marker,
|
||||
limit=limit,
|
||||
delimiter="/",
|
||||
delimiter=FOLDER_DELIMITER,
|
||||
path=path)
|
||||
filter_string_list = filter_string.lower().strip().split(' ')
|
||||
|
||||
|
18
horizon/browsers/__init__.py
Normal file
18
horizon/browsers/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2012 Nebula, 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.
|
||||
|
||||
from .base import ResourceBrowser
|
||||
from .views import ResourceBrowserView
|
105
horizon/browsers/base.py
Normal file
105
horizon/browsers/base.py
Normal file
@ -0,0 +1,105 @@
|
||||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2012 Nebula, 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.
|
||||
|
||||
from django import template
|
||||
|
||||
from horizon.tables import DataTable
|
||||
from horizon.utils import html
|
||||
|
||||
|
||||
class ResourceBrowser(html.HTMLElement):
|
||||
"""A class which defines a browser for displaying data.
|
||||
|
||||
.. attribute:: name
|
||||
|
||||
A short name or slug for the browser.
|
||||
|
||||
.. attribute:: verbose_name
|
||||
|
||||
A more verbose name for the browser meant for display purposes.
|
||||
|
||||
.. attribute:: navigation_table_class
|
||||
This table displays data on the left side of the browser.
|
||||
Set the ``navigation_table_class`` attribute with
|
||||
the desired :class:`~horizon.tables.DataTable` class.
|
||||
This table class must set browser_table attribute in Meta to
|
||||
``"navigation"``.
|
||||
|
||||
.. attribute:: content_table_class
|
||||
This table displays data on the right side of the browser.
|
||||
Set the ``content_table_class`` attribute with
|
||||
the desired :class:`~horizon.tables.DataTable` class.
|
||||
This table class must set browser_table attribute in Meta to
|
||||
``"content"``.
|
||||
|
||||
.. attribute:: template
|
||||
|
||||
String containing the template which should be used to render
|
||||
the browser. Defaults to ``"horizon/common/_resource_browser.html"``.
|
||||
|
||||
.. attribute:: context_var_name
|
||||
|
||||
The name of the context variable which will contain the browser when
|
||||
it is rendered. Defaults to ``"browser"``.
|
||||
"""
|
||||
name = None
|
||||
verbose_name = None
|
||||
navigation_table_class = None
|
||||
content_table_class = None
|
||||
template = "horizon/common/_resource_browser.html"
|
||||
context_var_name = "browser"
|
||||
|
||||
def __init__(self, request, tables=None, attrs=None,
|
||||
**kwargs):
|
||||
super(ResourceBrowser, self).__init__()
|
||||
self.name = getattr(self, "name", self.__class__.__name__)
|
||||
self.verbose_name = getattr(self, "verbose_name", self.name.title())
|
||||
self.request = request
|
||||
self.attrs.update(attrs or {})
|
||||
|
||||
self.navigation_table_class = getattr(self, "navigation_table_class",
|
||||
None)
|
||||
self.check_table_class(self.navigation_table_class,
|
||||
"navigation_table_class")
|
||||
|
||||
self.content_table_class = getattr(self, "content_table_class",
|
||||
None)
|
||||
self.check_table_class(self.content_table_class,
|
||||
"content_table_class")
|
||||
|
||||
self.set_tables(tables)
|
||||
|
||||
def check_table_class(self, cls, attr_name):
|
||||
if not cls or not issubclass(cls, (DataTable, )):
|
||||
raise ValueError("You must specify a DataTable class for "
|
||||
"the %s attribute on %s "
|
||||
% (attr_name, self.__class__.__name__))
|
||||
|
||||
def set_tables(self, tables):
|
||||
if tables:
|
||||
self.navigation_table = tables.get(self.navigation_table_class
|
||||
._meta.name, None)
|
||||
self.content_table = tables.get(self.content_table_class
|
||||
._meta.name, None)
|
||||
else:
|
||||
raise ValueError("There are no tables passed to class %s." %
|
||||
self.__class__.__name__)
|
||||
|
||||
def render(self):
|
||||
browser_template = template.loader.get_template(self.template)
|
||||
extra_context = {self.context_var_name: self}
|
||||
context = template.RequestContext(self.request, extra_context)
|
||||
return browser_template.render(context)
|
56
horizon/browsers/views.py
Normal file
56
horizon/browsers/views.py
Normal file
@ -0,0 +1,56 @@
|
||||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2012 Nebula, 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.
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from horizon.tables import MultiTableView
|
||||
|
||||
|
||||
class ResourceBrowserView(MultiTableView):
|
||||
browser_class = None
|
||||
data_method_pattern = "get_%s_data"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.browser_class = getattr(self, "browser_class", None)
|
||||
if not self.browser_class:
|
||||
raise ValueError("You must specify a ResourceBrowser class "
|
||||
" for the browser_class attribute on %s "
|
||||
% self.__class__.__name__)
|
||||
|
||||
self.navigation_table = self.browser_class.navigation_table_class
|
||||
self.content_table = self.browser_class.content_table_class
|
||||
|
||||
# Check and set up the method the view would use to collect data
|
||||
self._data_methods = defaultdict(list)
|
||||
self.table_classes = (self.navigation_table, self.content_table)
|
||||
self.get_data_methods(self.table_classes, self._data_methods)
|
||||
|
||||
self._tables = {}
|
||||
self._data = {}
|
||||
|
||||
def get_browser(self):
|
||||
if not hasattr(self, "browser"):
|
||||
tables = self.get_tables()
|
||||
self.browser = self.browser_class(self.request,
|
||||
tables,
|
||||
**self.kwargs)
|
||||
return self.browser
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(ResourceBrowserView, self).get_context_data(**kwargs)
|
||||
browser = self.get_browser()
|
||||
context["%s_browser" % browser.name] = browser
|
||||
return context
|
32
horizon/dashboards/nova/containers/browsers.py
Normal file
32
horizon/dashboards/nova/containers/browsers.py
Normal file
@ -0,0 +1,32 @@
|
||||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2012 Nebula, 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.
|
||||
|
||||
import logging
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import browsers
|
||||
from .tables import ContainersTable, ObjectsTable
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContainerBrowser(browsers.ResourceBrowser):
|
||||
name = "swift"
|
||||
verbose_name = _("Swift")
|
||||
navigation_table_class = ContainersTable
|
||||
content_table_class = ObjectsTable
|
@ -114,7 +114,7 @@ class CopyObject(forms.SelfHandlingForm):
|
||||
self.fields['new_container_name'].choices = containers
|
||||
|
||||
def handle(self, request, data):
|
||||
object_index = "horizon:nova:containers:object_index"
|
||||
object_index = "horizon:nova:containers:index"
|
||||
orig_container = data['orig_container_name']
|
||||
orig_object = data['orig_object_name']
|
||||
new_container = data['new_container_name']
|
||||
|
@ -25,14 +25,21 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from horizon import api
|
||||
from horizon import messages
|
||||
from horizon import tables
|
||||
from horizon.api import FOLDER_DELIMITER
|
||||
from horizon.tables import DataTable
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def wrap_delimiter(name):
|
||||
return name + FOLDER_DELIMITER
|
||||
|
||||
|
||||
class DeleteContainer(tables.DeleteAction):
|
||||
data_type_singular = _("Container")
|
||||
data_type_plural = _("Containers")
|
||||
completion_url = "horizon:nova:containers:index"
|
||||
|
||||
def delete(self, request, obj_id):
|
||||
try:
|
||||
@ -42,6 +49,18 @@ class DeleteContainer(tables.DeleteAction):
|
||||
_('Containers must be empty before deletion.'))
|
||||
raise
|
||||
|
||||
def get_success_url(self, request=None):
|
||||
"""
|
||||
Returns the URL to redirect to after a successful action.
|
||||
"""
|
||||
current_container = self.table.kwargs.get("container_name", None)
|
||||
|
||||
# If the current_container is deleted, then redirect to the default
|
||||
# completion url
|
||||
if current_container in self.success_ids:
|
||||
return self.completion_url
|
||||
return request.get_full_path()
|
||||
|
||||
|
||||
class CreateContainer(tables.LinkAction):
|
||||
name = "create"
|
||||
@ -53,9 +72,14 @@ class CreateContainer(tables.LinkAction):
|
||||
class ListObjects(tables.LinkAction):
|
||||
name = "list_objects"
|
||||
verbose_name = _("View Container")
|
||||
url = "horizon:nova:containers:object_index"
|
||||
url = "horizon:nova:containers:index"
|
||||
classes = ("btn-list",)
|
||||
|
||||
def get_link_url(self, datum=None):
|
||||
container_name = http.urlquote(datum.name)
|
||||
args = (wrap_delimiter(container_name),)
|
||||
return reverse(self.url, args=args)
|
||||
|
||||
|
||||
class UploadObject(tables.LinkAction):
|
||||
name = "upload"
|
||||
@ -76,6 +100,11 @@ class UploadObject(tables.LinkAction):
|
||||
(container_name, subfolders) if bit)
|
||||
return reverse(self.url, args=args)
|
||||
|
||||
def allowed(self, request, datum=None):
|
||||
if self.table.kwargs.get('container_name', None):
|
||||
return True
|
||||
return False
|
||||
|
||||
def update(self, request, obj):
|
||||
# This will only be called for the row, so we can remove the button
|
||||
# styles meant for the table action version.
|
||||
@ -86,15 +115,14 @@ def get_size_used(container):
|
||||
return filesizeformat(container.size_used)
|
||||
|
||||
|
||||
def get_container_link(container):
|
||||
return reverse("horizon:nova:containers:index",
|
||||
args=(http.urlquote(wrap_delimiter(container.name)),))
|
||||
|
||||
|
||||
class ContainersTable(tables.DataTable):
|
||||
name = tables.Column("name", link='horizon:nova:containers:object_index',
|
||||
name = tables.Column("name", link=get_container_link,
|
||||
verbose_name=_("Container Name"))
|
||||
objects = tables.Column("object_count",
|
||||
verbose_name=_('Objects'),
|
||||
empty_value="0")
|
||||
size = tables.Column(get_size_used,
|
||||
verbose_name=_('Size'),
|
||||
attrs={'data-type': 'size'})
|
||||
|
||||
def get_object_id(self, container):
|
||||
return container.name
|
||||
@ -102,8 +130,9 @@ class ContainersTable(tables.DataTable):
|
||||
class Meta:
|
||||
name = "containers"
|
||||
verbose_name = _("Containers")
|
||||
table_actions = (CreateContainer, DeleteContainer)
|
||||
table_actions = (CreateContainer,)
|
||||
row_actions = (ListObjects, UploadObject, DeleteContainer)
|
||||
browser_table = "navigation"
|
||||
|
||||
|
||||
class DeleteObject(tables.DeleteAction):
|
||||
@ -127,8 +156,8 @@ class DeleteSubfolder(DeleteObject):
|
||||
|
||||
class DeleteMultipleObjects(DeleteObject):
|
||||
name = "delete_multiple_objects"
|
||||
data_type_singular = _("Object/Folder")
|
||||
data_type_plural = _("Objects/Folders")
|
||||
data_type_singular = _("Object")
|
||||
data_type_plural = _("Objects")
|
||||
allowed_data_types = ("subfolders", "objects",)
|
||||
|
||||
|
||||
@ -161,7 +190,7 @@ class ObjectFilterAction(tables.FilterAction):
|
||||
request = table._meta.request
|
||||
container = self.table.kwargs['container_name']
|
||||
subfolder = self.table.kwargs['subfolder_path']
|
||||
path = subfolder + '/' if subfolder else ''
|
||||
path = subfolder + FOLDER_DELIMITER if subfolder else ''
|
||||
self.filtered_data = api.swift_filter_objects(request,
|
||||
filter_string,
|
||||
container,
|
||||
@ -178,9 +207,14 @@ class ObjectFilterAction(tables.FilterAction):
|
||||
return [datum for datum in data if
|
||||
datum.content_type != "application/directory"]
|
||||
|
||||
def allowed(self, request, datum=None):
|
||||
if self.table.kwargs.get('container_name', None):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def sanitize_name(name):
|
||||
return name.split("/")[-1]
|
||||
return name.split(FOLDER_DELIMITER)[-1]
|
||||
|
||||
|
||||
def get_size(obj):
|
||||
@ -188,9 +222,10 @@ def get_size(obj):
|
||||
|
||||
|
||||
def get_link_subfolder(subfolder):
|
||||
return reverse("horizon:nova:containers:object_index",
|
||||
args=(http.urlquote(subfolder.container.name),
|
||||
http.urlquote(subfolder.name + "/")))
|
||||
container_name = subfolder.container.name
|
||||
return reverse("horizon:nova:containers:index",
|
||||
args=(http.urlquote(wrap_delimiter(container_name)),
|
||||
http.urlquote(wrap_delimiter(subfolder.name))))
|
||||
|
||||
|
||||
class CreateSubfolder(CreateContainer):
|
||||
@ -200,9 +235,15 @@ class CreateSubfolder(CreateContainer):
|
||||
def get_link_url(self):
|
||||
container = self.table.kwargs['container_name']
|
||||
subfolders = self.table.kwargs['subfolder_path']
|
||||
parent = "/".join((bit for bit in [container, subfolders] if bit))
|
||||
parent = parent.rstrip("/")
|
||||
return reverse(self.url, args=(http.urlquote(parent + "/"),))
|
||||
parent = FOLDER_DELIMITER.join((bit for bit in [container,
|
||||
subfolders] if bit))
|
||||
parent = parent.rstrip(FOLDER_DELIMITER)
|
||||
return reverse(self.url, args=[http.urlquote(wrap_delimiter(parent))])
|
||||
|
||||
def allowed(self, request, datum=None):
|
||||
if self.table.kwargs.get('container_name', None):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ObjectsTable(tables.DataTable):
|
||||
@ -211,6 +252,7 @@ class ObjectsTable(tables.DataTable):
|
||||
allowed_data_types=("subfolders",),
|
||||
verbose_name=_("Object Name"),
|
||||
filters=(sanitize_name,))
|
||||
|
||||
size = tables.Column(get_size, verbose_name=_('Size'))
|
||||
|
||||
def get_object_id(self, obj):
|
||||
@ -218,9 +260,10 @@ class ObjectsTable(tables.DataTable):
|
||||
|
||||
class Meta:
|
||||
name = "objects"
|
||||
verbose_name = _("Subfolders and Objects")
|
||||
verbose_name = _("Objects")
|
||||
table_actions = (ObjectFilterAction, CreateSubfolder,
|
||||
UploadObject, DeleteMultipleObjects)
|
||||
row_actions = (DownloadObject, CopyObject, DeleteObject,
|
||||
DeleteSubfolder)
|
||||
data_types = ("subfolders", "objects")
|
||||
browser_table = "content"
|
||||
|
@ -20,5 +20,5 @@
|
||||
|
||||
{% block modal-footer %}
|
||||
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Copy Object" %}" />
|
||||
<a href="{% url horizon:nova:containers:object_index container_name %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
|
||||
<a href="{% url horizon:nova:containers:index container_name|add:'/' %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
|
||||
{% endblock %}
|
||||
|
@ -21,5 +21,5 @@
|
||||
|
||||
{% block modal-footer %}
|
||||
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Upload Object" %}" />
|
||||
<a href="{% url horizon:nova:containers:object_index container_name %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
|
||||
<a href="{% url horizon:nova:containers:index container_name|add:'/' %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
|
||||
{% endblock %}
|
||||
|
@ -1,32 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Objects" %}{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
<div class='page-header'>
|
||||
<h2>{% trans "Container" %}:
|
||||
{% if subfolders %}
|
||||
<a href="{% url horizon:nova:containers:object_index container_name=container_name %}">{{container_name}}</a>
|
||||
<small>/</small>
|
||||
{% else %}
|
||||
{{container_name}}
|
||||
{% endif %}
|
||||
{% for subfolder, path in subfolders %}
|
||||
<small>
|
||||
{% if not forloop.last %}
|
||||
<a href="{% url horizon:nova:containers:object_index container_name=container_name subfolder_path=path %}">
|
||||
{% endif %}{{ subfolder }}{% if not forloop.last %}</a> /{% endif %}
|
||||
</small>
|
||||
{% endfor %}
|
||||
</h2>
|
||||
</div>
|
||||
{% endblock page_header %}
|
||||
|
||||
{% block main %}
|
||||
<div id="subfolders">
|
||||
{{ subfolders_table.render }}
|
||||
</div>
|
||||
<div id="objects">
|
||||
{{ objects_table.render }}
|
||||
</div>
|
||||
{% endblock %}
|
@ -3,9 +3,25 @@
|
||||
{% block title %}Containers{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
{% include "horizon/common/_page_header.html" with title=_("Containers") %}
|
||||
<div class='page-header'>
|
||||
<h2>{% trans "Container" %}
|
||||
{% if subfolders %}
|
||||
: <a href="{% url horizon:nova:containers:index container_name|add:'/' %}">{{container_name}}</a>
|
||||
<small>/</small>
|
||||
{% elif container_name %}
|
||||
: {{container_name}}
|
||||
{% endif %}
|
||||
{% for subfolder, path in subfolders %}
|
||||
<small>
|
||||
{% if not forloop.last %}
|
||||
<a href="{% url horizon:nova:containers:index container_name|add:'/' path %}">
|
||||
{% endif %}{{ subfolder }}{% if not forloop.last %}</a> /{% endif %}
|
||||
</small>
|
||||
{% endfor %}
|
||||
</h2>
|
||||
</div>
|
||||
{% endblock page_header %}
|
||||
|
||||
{% block main %}
|
||||
{{ table.render }}
|
||||
{{ swift_browser.render }}
|
||||
{% endblock %}
|
||||
|
@ -28,7 +28,7 @@ from mox import IsA
|
||||
|
||||
from horizon import api
|
||||
from horizon import test
|
||||
from .tables import ContainersTable, ObjectsTable
|
||||
from .tables import ContainersTable, ObjectsTable, wrap_delimiter
|
||||
from . import forms
|
||||
|
||||
|
||||
@ -93,50 +93,30 @@ class ContainerViewTests(test.TestCase):
|
||||
'method': forms.CreateContainer.__name__}
|
||||
res = self.client.post(reverse('horizon:nova:containers:create'),
|
||||
formData)
|
||||
url = reverse('horizon:nova:containers:object_index',
|
||||
args=[self.containers.first().name])
|
||||
url = reverse('horizon:nova:containers:index',
|
||||
args=[wrap_delimiter(self.containers.first().name)])
|
||||
self.assertRedirectsNoFollow(res, url)
|
||||
|
||||
|
||||
class ObjectViewTests(test.TestCase):
|
||||
@test.create_stubs({api: ('swift_get_objects',)})
|
||||
class IndexViewTests(test.TestCase):
|
||||
def test_index(self):
|
||||
self.mox.StubOutWithMock(api, 'swift_get_containers')
|
||||
self.mox.StubOutWithMock(api, 'swift_get_objects')
|
||||
containers = (self.containers.list(), False)
|
||||
ret = (self.objects.list(), False)
|
||||
api.swift_get_containers(IsA(http.HttpRequest),
|
||||
marker=None).AndReturn(containers)
|
||||
api.swift_get_objects(IsA(http.HttpRequest),
|
||||
self.containers.first().name,
|
||||
marker=None,
|
||||
path=None).AndReturn(ret)
|
||||
self.mox.ReplayAll()
|
||||
|
||||
res = self.client.get(reverse('horizon:nova:containers:object_index',
|
||||
args=[self.containers.first().name]))
|
||||
self.assertEquals(res.context['container_name'],
|
||||
self.containers.first().name)
|
||||
self.assertTemplateUsed(res, 'nova/containers/detail.html')
|
||||
# UTF8 encoding here to ensure there aren't problems with Nose output.
|
||||
expected = [obj.name.encode('utf8') for obj in self.objects.list()]
|
||||
self.assertQuerysetEqual(res.context['objects_table'].data,
|
||||
expected,
|
||||
lambda obj: obj.name.encode('utf8'))
|
||||
|
||||
@test.create_stubs({api: ('swift_get_objects',)})
|
||||
def test_index_subfolders(self):
|
||||
ret = (self.objects.list(), False)
|
||||
api.swift_get_objects(IsA(http.HttpRequest),
|
||||
self.containers.first().name,
|
||||
marker=None,
|
||||
path='sub1/sub2').AndReturn(ret)
|
||||
self.mox.ReplayAll()
|
||||
|
||||
res = self.client.get(reverse('horizon:nova:containers:object_index',
|
||||
args=[self.containers.first().name,
|
||||
u'sub1/sub2/']))
|
||||
self.assertEquals(res.context['container_name'],
|
||||
self.containers.first().name)
|
||||
self.assertListEqual(res.context['subfolders'],
|
||||
[('sub1', 'sub1/'),
|
||||
('sub2', 'sub1/sub2/'), ])
|
||||
self.assertTemplateUsed(res, 'nova/containers/detail.html')
|
||||
res = self.client.get(reverse('horizon:nova:containers:index',
|
||||
args=[wrap_delimiter(self.containers
|
||||
.first()
|
||||
.name)]))
|
||||
self.assertTemplateUsed(res, 'nova/containers/index.html')
|
||||
# UTF8 encoding here to ensure there aren't problems with Nose output.
|
||||
expected = [obj.name.encode('utf8') for obj in self.objects.list()]
|
||||
self.assertQuerysetEqual(res.context['objects_table'].data,
|
||||
@ -177,8 +157,8 @@ class ObjectViewTests(test.TestCase):
|
||||
'object_file': temp_file}
|
||||
res = self.client.post(upload_url, formData)
|
||||
|
||||
index_url = reverse('horizon:nova:containers:object_index',
|
||||
args=[container.name])
|
||||
index_url = reverse('horizon:nova:containers:index',
|
||||
args=[wrap_delimiter(container.name)])
|
||||
self.assertRedirectsNoFollow(res, index_url)
|
||||
|
||||
# Test invalid filename
|
||||
@ -197,8 +177,8 @@ class ObjectViewTests(test.TestCase):
|
||||
def test_delete(self):
|
||||
container = self.containers.first()
|
||||
obj = self.objects.first()
|
||||
index_url = reverse('horizon:nova:containers:object_index',
|
||||
args=[container.name])
|
||||
index_url = reverse('horizon:nova:containers:index',
|
||||
args=[wrap_delimiter(container.name)])
|
||||
self.mox.StubOutWithMock(api, 'swift_delete_object')
|
||||
api.swift_delete_object(IsA(http.HttpRequest),
|
||||
container.name,
|
||||
@ -269,6 +249,6 @@ class ObjectViewTests(test.TestCase):
|
||||
copy_url = reverse('horizon:nova:containers:object_copy',
|
||||
args=[container_1.name, obj.name])
|
||||
res = self.client.post(copy_url, formData)
|
||||
index_url = reverse('horizon:nova:containers:object_index',
|
||||
args=[container_2.name])
|
||||
index_url = reverse('horizon:nova:containers:index',
|
||||
args=[wrap_delimiter(container_2.name)])
|
||||
self.assertRedirectsNoFollow(res, index_url)
|
||||
|
@ -20,22 +20,19 @@
|
||||
|
||||
from django.conf.urls.defaults import patterns, url
|
||||
|
||||
from .views import IndexView, CreateView, UploadView, ObjectIndexView, CopyView
|
||||
from .views import CreateView, UploadView, CopyView, ContainerView
|
||||
|
||||
|
||||
# Swift containers and objects.
|
||||
urlpatterns = patterns('horizon.dashboards.nova.containers.views',
|
||||
url(r'^$', IndexView.as_view(), name='index'),
|
||||
url(r'^((?P<container_name>.+?)/)?(?P<subfolder_path>(.+/)+)?$',
|
||||
ContainerView.as_view(), name='index'),
|
||||
|
||||
url(r'^(?P<container_name>(.+/)+)?create$',
|
||||
CreateView.as_view(),
|
||||
name='create'),
|
||||
|
||||
url(r'^(?P<container_name>[^/]+)/(?P<subfolder_path>(.+/)+)?$',
|
||||
ObjectIndexView.as_view(),
|
||||
name='object_index'),
|
||||
|
||||
url(r'^(?P<container_name>[^/]+)/(?P<subfolder_path>(.+/)+)?upload$',
|
||||
url(r'^(?P<container_name>.+?)/(?P<subfolder_path>(.+/)+)?upload$',
|
||||
UploadView.as_view(),
|
||||
name='object_upload'),
|
||||
|
||||
|
@ -21,7 +21,6 @@
|
||||
"""
|
||||
Views for managing Swift containers.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django import http
|
||||
@ -29,24 +28,20 @@ from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import api
|
||||
from horizon import browsers
|
||||
from horizon import exceptions
|
||||
from horizon import forms
|
||||
from horizon import tables
|
||||
from horizon.api import FOLDER_DELIMITER
|
||||
from .browsers import ContainerBrowser
|
||||
from .forms import CreateContainer, UploadObject, CopyObject
|
||||
from .tables import ContainersTable, ObjectsTable
|
||||
from .tables import wrap_delimiter
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
class ContainerView(browsers.ResourceBrowserView):
|
||||
browser_class = ContainerBrowser
|
||||
template_name = "nova/containers/index.html"
|
||||
|
||||
|
||||
class IndexView(tables.DataTableView):
|
||||
table_class = ContainersTable
|
||||
template_name = 'nova/containers/index.html'
|
||||
|
||||
def has_more_data(self, table):
|
||||
return self._more
|
||||
|
||||
def get_data(self):
|
||||
def get_containers_data(self):
|
||||
containers = []
|
||||
self._more = None
|
||||
marker = self.request.GET.get('marker', None)
|
||||
@ -58,35 +53,6 @@ class IndexView(tables.DataTableView):
|
||||
exceptions.handle(self.request, msg)
|
||||
return containers
|
||||
|
||||
|
||||
class CreateView(forms.ModalFormView):
|
||||
form_class = CreateContainer
|
||||
template_name = 'nova/containers/create.html'
|
||||
success_url = "horizon:nova:containers:object_index"
|
||||
|
||||
def get_success_url(self):
|
||||
parent = self.request.POST.get('parent', None)
|
||||
if parent:
|
||||
container, slash, remainder = parent.partition("/")
|
||||
if remainder and not remainder.endswith("/"):
|
||||
remainder = "".join([remainder, "/"])
|
||||
return reverse(self.success_url, args=(container, remainder))
|
||||
else:
|
||||
return reverse(self.success_url, args=[self.request.POST['name']])
|
||||
|
||||
def get_initial(self):
|
||||
initial = super(CreateView, self).get_initial()
|
||||
initial['parent'] = self.kwargs['container_name']
|
||||
return initial
|
||||
|
||||
|
||||
class ObjectIndexView(tables.MixedDataTableView):
|
||||
table_class = ObjectsTable
|
||||
template_name = 'nova/containers/detail.html'
|
||||
|
||||
def has_more_data(self, table):
|
||||
return self._more
|
||||
|
||||
@property
|
||||
def objects(self):
|
||||
""" Returns a list of objects given the subfolder's path.
|
||||
@ -99,20 +65,20 @@ class ObjectIndexView(tables.MixedDataTableView):
|
||||
marker = self.request.GET.get('marker', None)
|
||||
container_name = self.kwargs['container_name']
|
||||
subfolders = self.kwargs['subfolder_path']
|
||||
if subfolders:
|
||||
prefix = subfolders.rstrip("/")
|
||||
else:
|
||||
prefix = None
|
||||
try:
|
||||
objects, self._more = api.swift_get_objects(self.request,
|
||||
container_name,
|
||||
marker=marker,
|
||||
path=prefix)
|
||||
except:
|
||||
self._more = None
|
||||
objects = []
|
||||
msg = _('Unable to retrieve object list.')
|
||||
exceptions.handle(self.request, msg)
|
||||
prefix = None
|
||||
if container_name:
|
||||
if subfolders:
|
||||
prefix = subfolders.rstrip(FOLDER_DELIMITER)
|
||||
try:
|
||||
objects, self._more = api.swift_get_objects(self.request,
|
||||
container_name,
|
||||
marker=marker,
|
||||
path=prefix)
|
||||
except:
|
||||
self._more = None
|
||||
objects = []
|
||||
msg = _('Unable to retrieve object list.')
|
||||
exceptions.handle(self.request, msg)
|
||||
self._objects = objects
|
||||
return self._objects
|
||||
|
||||
@ -134,7 +100,7 @@ class ObjectIndexView(tables.MixedDataTableView):
|
||||
return filtered_objects
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(ObjectIndexView, self).get_context_data(**kwargs)
|
||||
context = super(ContainerView, self).get_context_data(**kwargs)
|
||||
context['container_name'] = self.kwargs["container_name"]
|
||||
context['subfolders'] = []
|
||||
if self.kwargs["subfolder_path"]:
|
||||
@ -147,14 +113,38 @@ class ObjectIndexView(tables.MixedDataTableView):
|
||||
return context
|
||||
|
||||
|
||||
class CreateView(forms.ModalFormView):
|
||||
form_class = CreateContainer
|
||||
template_name = 'nova/containers/create.html'
|
||||
success_url = "horizon:nova:containers:index"
|
||||
|
||||
def get_success_url(self):
|
||||
parent = self.request.POST.get('parent', None)
|
||||
if parent:
|
||||
container, slash, remainder = parent.partition(FOLDER_DELIMITER)
|
||||
container += FOLDER_DELIMITER
|
||||
if remainder and not remainder.endswith(FOLDER_DELIMITER):
|
||||
remainder = "".join([remainder, FOLDER_DELIMITER])
|
||||
return reverse(self.success_url, args=(container, remainder))
|
||||
else:
|
||||
return reverse(self.success_url, args=[self.request.POST['name'] +
|
||||
FOLDER_DELIMITER])
|
||||
|
||||
def get_initial(self):
|
||||
initial = super(CreateView, self).get_initial()
|
||||
initial['parent'] = self.kwargs['container_name']
|
||||
return initial
|
||||
|
||||
|
||||
class UploadView(forms.ModalFormView):
|
||||
form_class = UploadObject
|
||||
template_name = 'nova/containers/upload.html'
|
||||
success_url = "horizon:nova:containers:object_index"
|
||||
success_url = "horizon:nova:containers:index"
|
||||
|
||||
def get_success_url(self):
|
||||
container_name = self.request.POST['container_name']
|
||||
return reverse(self.success_url,
|
||||
args=(self.request.POST['container_name'],
|
||||
args=(wrap_delimiter(container_name),
|
||||
self.request.POST.get('path', '')))
|
||||
|
||||
def get_initial(self):
|
||||
@ -171,7 +161,7 @@ def object_download(request, container_name, object_path):
|
||||
obj = api.swift.swift_get_object(request, container_name, object_path)
|
||||
# Add the original file extension back on if it wasn't preserved in the
|
||||
# name given to the object.
|
||||
filename = object_path.rsplit("/")[-1]
|
||||
filename = object_path.rsplit(FOLDER_DELIMITER)[-1]
|
||||
if not os.path.splitext(obj.name)[1]:
|
||||
name, ext = os.path.splitext(obj.metadata.get('orig-filename', ''))
|
||||
filename = "%s%s" % (filename, ext)
|
||||
@ -196,11 +186,12 @@ def object_download(request, container_name, object_path):
|
||||
class CopyView(forms.ModalFormView):
|
||||
form_class = CopyObject
|
||||
template_name = 'nova/containers/copy.html'
|
||||
success_url = "horizon:nova:containers:object_index"
|
||||
success_url = "horizon:nova:containers:index"
|
||||
|
||||
def get_success_url(self):
|
||||
new_container_name = self.request.POST['new_container_name']
|
||||
return reverse(self.success_url,
|
||||
args=(self.request.POST['new_container_name'],
|
||||
args=(wrap_delimiter(new_container_name),
|
||||
self.request.POST.get('path', '')))
|
||||
|
||||
def get_form_kwargs(self):
|
||||
|
@ -51,12 +51,13 @@ class UsageViewTests(test.BaseAdminViewTests):
|
||||
self.assertTemplateUsed(res, 'syspanel/overview/usage.html')
|
||||
self.assertTrue(isinstance(res.context['usage'], usage.GlobalUsage))
|
||||
self.assertContains(res,
|
||||
'<td class="sortable">test_tenant</td>'
|
||||
'<td class="sortable">%s</td>'
|
||||
'<td class="sortable">%s</td>'
|
||||
'<td class="sortable">%s</td>'
|
||||
'<td class="sortable">%.2f</td>'
|
||||
'<td class="sortable">%.2f</td>' %
|
||||
'<td class="sortable normal_column">test_tenant'
|
||||
'</td>'
|
||||
'<td class="sortable normal_column">%s</td>'
|
||||
'<td class="sortable normal_column">%s</td>'
|
||||
'<td class="sortable normal_column">%s</td>'
|
||||
'<td class="sortable normal_column">%.2f</td>'
|
||||
'<td class="sortable normal_column">%.2f</td>' %
|
||||
(usage_obj.vcpus,
|
||||
usage_obj.disk_gb_hours,
|
||||
mbformat(usage_obj.memory_mb),
|
||||
|
@ -439,6 +439,8 @@ class BatchAction(Action):
|
||||
self._conjugate())
|
||||
self.verbose_name_plural = getattr(self, "verbose_name_plural",
|
||||
self._conjugate('plural'))
|
||||
# Keep record of successfully handled objects
|
||||
self.success_ids = []
|
||||
super(BatchAction, self).__init__()
|
||||
|
||||
def _allowed(self, request, datum=None):
|
||||
@ -508,6 +510,7 @@ class BatchAction(Action):
|
||||
#Call update to invoke changes if needed
|
||||
self.update(request, datum)
|
||||
action_success.append(datum_display)
|
||||
self.success_ids.append(datum_id)
|
||||
LOG.info('%s: "%s"' %
|
||||
(self._conjugate(past=True), datum_display))
|
||||
except:
|
||||
|
@ -692,11 +692,13 @@ class DataTableOptions(object):
|
||||
Optional. Default: :``False``
|
||||
|
||||
.. attribute:: data_types
|
||||
|
||||
A list of data types that this table would accept. Default to be an
|
||||
empty list, but if the attibute ``mixed_data_type`` is set to ``True``,
|
||||
then this list must have at least one element.
|
||||
|
||||
.. attribute:: data_type_name
|
||||
|
||||
The name of an attribute to assign to data passed to the table when it
|
||||
accepts mix data. Default: ``"_table_data_type"``
|
||||
"""
|
||||
@ -712,6 +714,7 @@ class DataTableOptions(object):
|
||||
self.row_class = getattr(options, 'row_class', Row)
|
||||
self.column_class = getattr(options, 'column_class', Column)
|
||||
self.pagination_param = getattr(options, 'pagination_param', 'marker')
|
||||
self.browser_table = getattr(options, 'browser_table', None)
|
||||
|
||||
# Set self.filter if we have any FilterActions
|
||||
filter_actions = [action for action in self.table_actions if
|
||||
@ -767,6 +770,7 @@ class DataTableMetaclass(type):
|
||||
""" Metaclass to add options to DataTable class and collect columns. """
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
# Process options from Meta
|
||||
class_name = name
|
||||
attrs["_meta"] = opts = DataTableOptions(attrs.get("Meta", None))
|
||||
|
||||
# Gather columns; this prevents the column from being an attribute
|
||||
@ -776,6 +780,7 @@ class DataTableMetaclass(type):
|
||||
if issubclass(type(obj), (opts.column_class, Column)):
|
||||
column_instance = attrs.pop(name)
|
||||
column_instance.name = name
|
||||
column_instance.classes.append('normal_column')
|
||||
columns.append((name, column_instance))
|
||||
columns.sort(key=lambda x: x[1].creation_counter)
|
||||
|
||||
@ -785,6 +790,15 @@ class DataTableMetaclass(type):
|
||||
columns = base.base_columns.items() + columns
|
||||
attrs['base_columns'] = SortedDict(columns)
|
||||
|
||||
# If the table is in a ResourceBrowser, the column number must meet
|
||||
# these limits because of the width of the browser.
|
||||
if opts.browser_table == "navigation" and len(columns) > 1:
|
||||
raise ValueError("You can only assign one column to %s."
|
||||
% class_name)
|
||||
if opts.browser_table == "content" and len(columns) > 2:
|
||||
raise ValueError("You can only assign two columns to %s."
|
||||
% class_name)
|
||||
|
||||
if opts.columns:
|
||||
# Remove any columns that weren't declared if we're being explicit
|
||||
# NOTE: we're iterating a COPY of the list here!
|
||||
@ -794,7 +808,7 @@ class DataTableMetaclass(type):
|
||||
# Re-order based on declared columns
|
||||
columns.sort(key=lambda x: attrs['_meta'].columns.index(x[0]))
|
||||
# Add in our auto-generated columns
|
||||
if opts.multi_select:
|
||||
if opts.multi_select and opts.browser_table != "navigation":
|
||||
multi_select = opts.column_class("multi_select",
|
||||
verbose_name="",
|
||||
auto="multi_select")
|
||||
@ -937,6 +951,11 @@ class DataTable(object):
|
||||
LOG.exception("Error while checking action permissions.")
|
||||
return None
|
||||
|
||||
def is_browser_table(self):
|
||||
if self._meta.browser_table:
|
||||
return True
|
||||
return False
|
||||
|
||||
def render(self):
|
||||
""" Renders the table using the template from the table options. """
|
||||
table_template = template.loader.get_template(self._meta.template)
|
||||
@ -1036,7 +1055,8 @@ class DataTable(object):
|
||||
table_actions_template = template.loader.get_template(template_path)
|
||||
bound_actions = self.get_table_actions()
|
||||
extra_context = {"table_actions": bound_actions}
|
||||
if self._meta.filter:
|
||||
if self._meta.filter and \
|
||||
self._filter_action(self._meta._filter_action, self._meta.request):
|
||||
extra_context["filter"] = self._meta._filter_action
|
||||
context = template.RequestContext(self._meta.request, extra_context)
|
||||
return table_actions_template.render(context)
|
||||
|
@ -14,29 +14,74 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from django.views import generic
|
||||
|
||||
|
||||
class MultiTableMixin(object):
|
||||
""" A generic mixin which provides methods for handling DataTables. """
|
||||
data_method_pattern = "get_%s_data"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MultiTableMixin, self).__init__(*args, **kwargs)
|
||||
self.table_classes = getattr(self, "table_classes", [])
|
||||
self._data = {}
|
||||
self._tables = {}
|
||||
|
||||
self._data_methods = defaultdict(list)
|
||||
self.get_data_methods(self.table_classes, self._data_methods)
|
||||
|
||||
def _get_data_dict(self):
|
||||
if not self._data:
|
||||
for table in self.table_classes:
|
||||
func_name = "get_%s_data" % table._meta.name
|
||||
data_func = getattr(self, func_name, None)
|
||||
if data_func is None:
|
||||
cls_name = self.__class__.__name__
|
||||
raise NotImplementedError("You must define a %s method "
|
||||
"on %s." % (func_name, cls_name))
|
||||
self._data[table._meta.name] = data_func()
|
||||
data = []
|
||||
name = table._meta.name
|
||||
func_list = self._data_methods.get(name, [])
|
||||
for func in func_list:
|
||||
data.extend(func())
|
||||
self._data[name] = data
|
||||
return self._data
|
||||
|
||||
def get_data_methods(self, table_classes, methods):
|
||||
for table in table_classes:
|
||||
name = table._meta.name
|
||||
if table._meta.mixed_data_type:
|
||||
for data_type in table._meta.data_types:
|
||||
func = self.check_method_exist(self.data_method_pattern,
|
||||
data_type)
|
||||
if func:
|
||||
type_name = table._meta.data_type_name
|
||||
methods[name].append(self.wrap_func(func,
|
||||
type_name,
|
||||
data_type))
|
||||
else:
|
||||
func = self.check_method_exist(self.data_method_pattern,
|
||||
name)
|
||||
if func:
|
||||
methods[name].append(func)
|
||||
|
||||
def wrap_func(self, data_func, type_name, data_type):
|
||||
def final_data():
|
||||
data = data_func()
|
||||
self.assign_type_string(data, type_name, data_type)
|
||||
return data
|
||||
return final_data
|
||||
|
||||
def check_method_exist(self, func_pattern="%s", *names):
|
||||
func_name = func_pattern % names
|
||||
func = getattr(self, func_name, None)
|
||||
if not func or not callable(func):
|
||||
cls_name = self.__class__.__name__
|
||||
raise NotImplementedError("You must define a %s method"
|
||||
"in %s." % (func_name, cls_name))
|
||||
else:
|
||||
return func
|
||||
|
||||
def assign_type_string(self, data, type_name, data_type):
|
||||
for datum in data:
|
||||
setattr(datum, type_name, data_type)
|
||||
|
||||
def get_tables(self):
|
||||
if not self.table_classes:
|
||||
raise AttributeError('You must specify one or more DataTable '
|
||||
|
@ -11,11 +11,13 @@
|
||||
{{ table.render_table_actions }}
|
||||
</th>
|
||||
</tr>
|
||||
{% if not table.is_browser_table %}
|
||||
<tr>
|
||||
{% for column in columns %}
|
||||
<th {{ column.attr_string|safe }}>{{ column }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endif %}
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
@ -52,4 +54,4 @@
|
||||
{% endwith %}
|
||||
{% if needs_form_wrapper %}</form>{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
|
9
horizon/templates/horizon/common/_resource_browser.html
Normal file
9
horizon/templates/horizon/common/_resource_browser.html
Normal file
@ -0,0 +1,9 @@
|
||||
{% load i18n %}
|
||||
<div id="browser_wrapper">
|
||||
<div class="navigation_wrapper">
|
||||
{{ browser.navigation_table.render }}
|
||||
</div>
|
||||
<div class="content_wrapper">
|
||||
{{ browser.content_table.render }}
|
||||
</div>
|
||||
</div>
|
@ -359,7 +359,7 @@ class DataTableTests(test.TestCase):
|
||||
self.assertEqual(row3.cells['optional'].value, "N/A")
|
||||
# classes
|
||||
self.assertEqual(value_col.get_final_attrs().get('class', ""),
|
||||
"green blue sortable anchor")
|
||||
"green blue sortable anchor normal_column")
|
||||
# status
|
||||
cell_status = row.cells['status'].status
|
||||
self.assertEqual(cell_status, True)
|
||||
|
@ -105,3 +105,23 @@
|
||||
// Fluid grid
|
||||
@fluidGridColumnWidth: 6.382978723%;
|
||||
@fluidGridGutterWidth: 2.127659574%;
|
||||
|
||||
//ResourceBrowser
|
||||
@dataTableBorderWidth: 1px;
|
||||
@dataTableBorderColor: #DDD;
|
||||
|
||||
@multiSelectionWidth: 25px;
|
||||
@actionsColumnWidth: 150px;
|
||||
@actionsColumnPadding: 10px;
|
||||
|
||||
@navigationColWidth: 150px;
|
||||
@contentColWidth: 240px;
|
||||
|
||||
@smallButtonHeight: 28px;
|
||||
@tbodyHeight: (@dataTableBorderWidth + @smallButtonHeight + @actionsColumnPadding) * 10;
|
||||
|
||||
@tableCellPadding: 8px;
|
||||
|
||||
@contentTableWidth: @multiSelectionWidth + @contentColWidth * 2 + @actionsColumnWidth + @actionsColumnPadding * 2 + @tableCellPadding * 6 + @dataTableBorderWidth * 3;
|
||||
@navigationTableWidth: (@navigationColWidth + @actionsColumnPadding + @tableCellPadding) * 2 + @dataTableBorderWidth * 3;
|
||||
@browserWrapperWidth: @contentTableWidth + @navigationTableWidth;
|
||||
|
@ -1389,3 +1389,98 @@ label.log-length {
|
||||
padding-right: 5px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
/* ResourceBrowser style
|
||||
*/
|
||||
#browser_wrapper {
|
||||
width: @browserWrapperWidth;
|
||||
> div{
|
||||
position: relative;
|
||||
padding: 55px 0 32px 0;
|
||||
float: left;
|
||||
background-color: @grayLighter;
|
||||
}
|
||||
div.table_wrapper {
|
||||
height: @tbodyHeight;
|
||||
border-left: @dataTableBorderWidth solid @dataTableBorderColor;
|
||||
border-right: @dataTableBorderWidth solid @dataTableBorderColor;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
div.navigation_wrapper {
|
||||
width: @navigationTableWidth;
|
||||
div.table_wrapper,
|
||||
thead th.table_header {
|
||||
width: @navigationTableWidth - 2px;
|
||||
}
|
||||
td {
|
||||
background-color: whiteSmoke;
|
||||
}
|
||||
td.normal_column{
|
||||
width: @navigationColWidth;
|
||||
min-width: @navigationColWidth;
|
||||
> a {
|
||||
width: @navigationColWidth;
|
||||
min-width: @navigationColWidth;
|
||||
}
|
||||
}
|
||||
tfoot td {
|
||||
width: @navigationTableWidth - 2 * @dataTableBorderWidth - 2 * @tableCellPadding;
|
||||
}
|
||||
}
|
||||
div.content_wrapper {
|
||||
width: @contentTableWidth;
|
||||
div.table_wrapper,
|
||||
thead th.table_header {
|
||||
width: @contentTableWidth - 2px;
|
||||
}
|
||||
td.normal_column {
|
||||
width: @contentColWidth;
|
||||
min-width: @contentColWidth;
|
||||
> a {
|
||||
width: @contentColWidth;
|
||||
min-width: @contentColWidth;
|
||||
}
|
||||
}
|
||||
tfoot td {
|
||||
width: @contentTableWidth - 2 * @dataTableBorderWidth - 2 * @tableCellPadding;
|
||||
}
|
||||
}
|
||||
table {
|
||||
thead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
tr th {
|
||||
border: @dataTableBorderWidth solid @dataTableBorderColor;
|
||||
border-bottom: none;
|
||||
background-color: @grayLighter;
|
||||
}
|
||||
}
|
||||
td.multi_select_column,
|
||||
th.multi_select_column{
|
||||
width: @multiSelectionWidth;
|
||||
}
|
||||
td.actions_column,
|
||||
th.actions_column{
|
||||
padding :@actionsColumnPadding;
|
||||
width: @actionsColumnWidth;
|
||||
}
|
||||
tbody {
|
||||
tr td:first-child{
|
||||
border-left: none;
|
||||
}
|
||||
tr td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
tr:last-child td {
|
||||
border-bottom: @dataTableBorderWidth solid @dataTableBorderColor;
|
||||
}
|
||||
}
|
||||
tfoot td{
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user