Implements reusable tab components.
Adds a fully-featured tabbed interface component, with support for dynamic loading via AJAX and class-based views to handle the heavy lifting. Cleaned up the instance detail view as a POC. Implements blueprint reusable-tabs. Change-Id: I3adbf1f1e4e95ae210d36477031a433c13f3d77c
This commit is contained in:
parent
27915bd5e3
commit
fca0b641a7
@ -84,6 +84,7 @@ In-depth documentation for Horizon and it's APIs.
|
||||
ref/run_tests
|
||||
ref/horizon
|
||||
ref/tables
|
||||
ref/tabs
|
||||
ref/users
|
||||
ref/forms
|
||||
ref/views
|
||||
|
37
docs/source/ref/tabs.rst
Normal file
37
docs/source/ref/tabs.rst
Normal file
@ -0,0 +1,37 @@
|
||||
==========================
|
||||
Horizon Tabs and TabGroups
|
||||
==========================
|
||||
|
||||
.. module:: horizon.tabs
|
||||
|
||||
Horizon includes a set of reusable components for programmatically
|
||||
building tabbed interfaces with fancy features like dynamic AJAX loading
|
||||
and nearly effortless templating and styling.
|
||||
|
||||
Tab Groups
|
||||
==========
|
||||
|
||||
For any tabbed interface, your fundamental element is the tab group which
|
||||
contains all your tabs. This class provides a dead-simple API for building
|
||||
tab groups and encapsulates all the necessary logic behind the scenes.
|
||||
|
||||
.. autoclass:: TabGroup
|
||||
:members:
|
||||
|
||||
Tabs
|
||||
====
|
||||
|
||||
The tab itself is the discrete unit for a tab group, representing one
|
||||
view of data.
|
||||
|
||||
.. autoclass:: Tab
|
||||
:members:
|
||||
|
||||
TabView
|
||||
=======
|
||||
|
||||
There is also a useful and simple generic class-based view for handling
|
||||
the display of a :class:`~horizon.tabs.TabGroup` class.
|
||||
|
||||
.. autoclass:: TabView
|
||||
:members:
|
@ -0,0 +1,74 @@
|
||||
# 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.utils.translation import ugettext as _
|
||||
|
||||
from horizon import api
|
||||
from horizon import exceptions
|
||||
from horizon import tabs
|
||||
|
||||
|
||||
class OverviewTab(tabs.Tab):
|
||||
name = _("Overview")
|
||||
slug = "overview"
|
||||
template_name = ("nova/instances_and_volumes/instances/"
|
||||
"_detail_overview.html")
|
||||
|
||||
def get_context_data(self, request):
|
||||
return {"instance": self.tab_group.kwargs['instance']}
|
||||
|
||||
|
||||
class LogTab(tabs.Tab):
|
||||
name = _("Log")
|
||||
slug = "log"
|
||||
template_name = "nova/instances_and_volumes/instances/_detail_log.html"
|
||||
preload = False
|
||||
|
||||
def get_context_data(self, request):
|
||||
instance = self.tab_group.kwargs['instance']
|
||||
try:
|
||||
data = api.server_console_output(request, instance.id)
|
||||
except:
|
||||
data = _('Unable to get log for instance "%s".') % instance.id
|
||||
exceptions.handle(request, ignore=True)
|
||||
return {"instance": instance,
|
||||
"console_log": data}
|
||||
|
||||
|
||||
class VNCTab(tabs.Tab):
|
||||
name = _("VNC")
|
||||
slug = "vnc"
|
||||
template_name = "nova/instances_and_volumes/instances/_detail_vnc.html"
|
||||
preload = False
|
||||
|
||||
def get_context_data(self, request):
|
||||
instance = self.tab_group.kwargs['instance']
|
||||
try:
|
||||
console = api.nova.server_vnc_console(request, instance.id)
|
||||
vnc_url = "%s&title=%s(%s)" % (console.url,
|
||||
getattr(instance, "name", ""),
|
||||
instance.id)
|
||||
except:
|
||||
vnc_url = None
|
||||
exceptions.handle(request,
|
||||
_('Unable to get vnc console for '
|
||||
'instance "%s".') % instance.id)
|
||||
return {'vnc_url': vnc_url}
|
||||
|
||||
|
||||
class InstanceDetailTabs(tabs.TabGroup):
|
||||
slug = "instance_details"
|
||||
tabs = (OverviewTab, LogTab, VNCTab)
|
@ -25,6 +25,7 @@ from novaclient import exceptions as nova_exceptions
|
||||
|
||||
from horizon import api
|
||||
from horizon import test
|
||||
from .tabs import InstanceDetailTabs
|
||||
|
||||
|
||||
INDEX_URL = reverse('horizon:nova:instances_and_volumes:index')
|
||||
@ -199,36 +200,38 @@ class InstanceViewTests(test.TestCase):
|
||||
res = self.client.post(INDEX_URL, formData)
|
||||
self.assertRedirectsNoFollow(res, INDEX_URL)
|
||||
|
||||
def test_instance_console(self):
|
||||
def test_instance_log(self):
|
||||
server = self.servers.first()
|
||||
CONSOLE_OUTPUT = 'output'
|
||||
|
||||
self.mox.StubOutWithMock(api, 'server_console_output')
|
||||
api.server_console_output(IsA(http.HttpRequest),
|
||||
server.id,
|
||||
tail_length=None).AndReturn(CONSOLE_OUTPUT)
|
||||
server.id).AndReturn(CONSOLE_OUTPUT)
|
||||
self.mox.ReplayAll()
|
||||
|
||||
url = reverse('horizon:nova:instances_and_volumes:instances:console',
|
||||
args=[server.id])
|
||||
res = self.client.get(url)
|
||||
tg = InstanceDetailTabs(self.request)
|
||||
qs = "?%s=%s" % (tg.param_name, tg.get_tab("log").get_id())
|
||||
res = self.client.get(url + qs)
|
||||
self.assertIsInstance(res, http.HttpResponse)
|
||||
self.assertContains(res, CONSOLE_OUTPUT)
|
||||
|
||||
def test_instance_console_exception(self):
|
||||
def test_instance_log_exception(self):
|
||||
server = self.servers.first()
|
||||
|
||||
self.mox.StubOutWithMock(api, 'server_console_output')
|
||||
exc = nova_exceptions.ClientException(500)
|
||||
api.server_console_output(IsA(http.HttpRequest),
|
||||
server.id,
|
||||
tail_length=None).AndRaise(exc)
|
||||
server.id).AndRaise(exc)
|
||||
self.mox.ReplayAll()
|
||||
|
||||
url = reverse('horizon:nova:instances_and_volumes:instances:console',
|
||||
args=[server.id])
|
||||
res = self.client.get(url)
|
||||
self.assertRedirectsNoFollow(res, INDEX_URL)
|
||||
tg = InstanceDetailTabs(self.request)
|
||||
qs = "?%s=%s" % (tg.param_name, tg.get_tab("log").get_id())
|
||||
res = self.client.get(url + qs)
|
||||
self.assertContains(res, "Unable to get log for")
|
||||
|
||||
def test_instance_vnc(self):
|
||||
server = self.servers.first()
|
||||
|
@ -32,8 +32,9 @@ from django.utils.translation import ugettext as _
|
||||
from horizon import api
|
||||
from horizon import exceptions
|
||||
from horizon import forms
|
||||
from horizon import views
|
||||
from horizon import tabs
|
||||
from .forms import UpdateInstance
|
||||
from .tabs import InstanceDetailTabs
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
@ -42,18 +43,14 @@ LOG = logging.getLogger(__name__)
|
||||
def console(request, instance_id):
|
||||
try:
|
||||
# TODO(jakedahn): clean this up once the api supports tailing.
|
||||
length = request.GET.get('length', None)
|
||||
console = api.server_console_output(request,
|
||||
instance_id,
|
||||
tail_length=length)
|
||||
response = http.HttpResponse(mimetype='text/plain')
|
||||
response.write(console)
|
||||
response.flush()
|
||||
return response
|
||||
data = api.server_console_output(request, instance_id)
|
||||
except:
|
||||
msg = _('Unable to get log for instance "%s".') % instance_id
|
||||
redirect = reverse('horizon:nova:instances_and_volumes:index')
|
||||
exceptions.handle(request, msg, redirect=redirect)
|
||||
data = _('Unable to get log for instance "%s".') % instance_id
|
||||
exceptions.handle(request, ignore=True)
|
||||
response = http.HttpResponse(mimetype='text/plain')
|
||||
response.write(data)
|
||||
response.flush()
|
||||
return response
|
||||
|
||||
|
||||
def vnc(request, instance_id):
|
||||
@ -90,47 +87,37 @@ class UpdateView(forms.ModalFormView):
|
||||
'name': getattr(self.object, 'name', '')}
|
||||
|
||||
|
||||
class DetailView(views.APIView):
|
||||
class DetailView(tabs.TabView):
|
||||
tab_group_class = InstanceDetailTabs
|
||||
template_name = 'nova/instances_and_volumes/instances/detail.html'
|
||||
|
||||
def get_data(self, request, context, *args, **kwargs):
|
||||
instance_id = kwargs['instance_id']
|
||||
|
||||
if "show" in request.GET:
|
||||
show_tab = request.GET["show"]
|
||||
else:
|
||||
show_tab = "overview"
|
||||
|
||||
try:
|
||||
instance = api.server_get(request, instance_id)
|
||||
volumes = api.volume_instance_list(request, instance_id)
|
||||
|
||||
# Gather our flavors and images and correlate our instances to
|
||||
# them. Exception handling happens in the parent class.
|
||||
flavors = api.flavor_list(request)
|
||||
full_flavors = SortedDict([(str(flavor.id), flavor) for \
|
||||
flavor in flavors])
|
||||
instance.full_flavor = full_flavors[instance.flavor["id"]]
|
||||
|
||||
context.update({'instance': instance, 'volumes': volumes})
|
||||
except:
|
||||
redirect = reverse('horizon:nova:instances_and_volumes:index')
|
||||
exceptions.handle(request,
|
||||
_('Unable to retrieve details for '
|
||||
'instance "%s".') % instance_id,
|
||||
redirect=redirect)
|
||||
if show_tab == "vnc":
|
||||
try:
|
||||
console = api.server_vnc_console(request, instance_id)
|
||||
vnc_url = "%s&title=%s(%s)" % (console.url,
|
||||
getattr(instance, "name", ""),
|
||||
instance_id)
|
||||
context.update({'vnc_url': vnc_url})
|
||||
except:
|
||||
exceptions.handle(request,
|
||||
_('Unable to get vnc console for '
|
||||
'instance "%s".') % instance_id)
|
||||
|
||||
context.update({'show_tab': show_tab})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(DetailView, self).get_context_data(**kwargs)
|
||||
context["instance"] = self.get_data()
|
||||
return context
|
||||
|
||||
def get_data(self):
|
||||
if not hasattr(self, "_instance"):
|
||||
try:
|
||||
instance_id = self.kwargs['instance_id']
|
||||
instance = api.server_get(self.request, instance_id)
|
||||
instance.volumes = api.volume_instance_list(self.request,
|
||||
instance_id)
|
||||
# Gather our flavors and images and correlate our instances to
|
||||
# them. Exception handling happens in the parent class.
|
||||
flavors = api.flavor_list(self.request)
|
||||
full_flavors = SortedDict([(str(flavor.id), flavor) for \
|
||||
flavor in flavors])
|
||||
instance.full_flavor = full_flavors[instance.flavor["id"]]
|
||||
except:
|
||||
redirect = reverse('horizon:nova:instances_and_volumes:index')
|
||||
exceptions.handle(self.request,
|
||||
_('Unable to retrieve details for '
|
||||
'instance "%s".') % instance_id,
|
||||
redirect=redirect)
|
||||
self._instance = instance
|
||||
return self._instance
|
||||
|
||||
def get_tabs(self, request, *args, **kwargs):
|
||||
instance = self.get_data()
|
||||
return self.tab_group_class(request, instance=instance, **kwargs)
|
||||
|
@ -0,0 +1,9 @@
|
||||
{% load i18n %}
|
||||
<div class="clearfix">
|
||||
<h3 class="pull-left">Instance Console Log</h3>
|
||||
<p class="pull-right">
|
||||
{% url horizon:nova:instances_and_volumes:instances:console instance.id as console_url %}
|
||||
<a class="btn btn-small" target="_blank" href="{{ console_url }}">{% trans "View Full Log" %}</a>
|
||||
</p>
|
||||
</div>
|
||||
<pre class="logs">{{ console_log }}</pre>
|
@ -0,0 +1,65 @@
|
||||
{% load i18n sizeformat %}
|
||||
|
||||
<h3>{% trans "Instance Overview" %}</h3>
|
||||
|
||||
<div class="status row-fluid">
|
||||
<h4>{% trans "Status" %}</h4>
|
||||
<hr class="header_rule">
|
||||
<ul>
|
||||
<li><strong>{% trans "Status" %}:</strong> {{ instance.status }}</li>
|
||||
<li><strong>{% trans "Instance Name" %}:</strong> {{ instance.name }}</li>
|
||||
<li><strong>{% trans "Instance ID" %}:</strong> {{ instance.id }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="specs row-fluid">
|
||||
<h4>{% trans "Specs" %}</h4>
|
||||
<hr class="header_rule">
|
||||
<ul>
|
||||
<li><strong>{% trans "RAM" %}:</strong> {{ instance.full_flavor.ram|mbformat }}</li>
|
||||
<li><strong>{% trans "VCPUs" %}:</strong> {{ instance.full_flavor.vcpus }} {% trans "VCPU" %}</li>
|
||||
<li><strong>{% trans "Disk" %}:</strong> {{ instance.full_flavor.disk }}{% trans "GB" %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="addresses row-fluid">
|
||||
<h4>{% trans "IP Addresses" %}</h4>
|
||||
<hr class="header_rule">
|
||||
<ul>
|
||||
{% for network, ip_list in instance.addresses.items %}
|
||||
<li><strong>{{ network|title }}:</strong>
|
||||
{% for ip in ip_list %}
|
||||
{% if not forloop.last %}{{ ip.addr}}, {% else %}{{ip.addr}}{% endif %}
|
||||
{% endfor %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="meta row-fluid">
|
||||
<h4>{% trans "Meta" %}</h4>
|
||||
<hr class="header_rule">
|
||||
<ul>
|
||||
<li><strong>{% trans "Key name" %}:</strong> {% if instance.key_name %}{{ instance.key_name }}{% else %}<em>None</em>{% endif %}</li>
|
||||
<li><strong>{% trans "Image Name" %}:</strong> {{ instance.image_name }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="volumes row-fluid">
|
||||
<h4>{% trans "Volumes" %}</h4>
|
||||
<hr class="header_rule">
|
||||
<ul>
|
||||
{% if volumes %}
|
||||
{% for volume in instance.volumes %}
|
||||
<li>
|
||||
<strong>{% trans "Volume" %}:</strong>
|
||||
<a href="{% url horizon:nova:instances_and_volumes:volumes:detail volume.volumeId %}">
|
||||
{{ volume.volumeId }} ({{ volume.device }})
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li><em>{% trans "No volumes attached." %}</em></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
@ -0,0 +1,5 @@
|
||||
{% load i18n %}
|
||||
|
||||
<h3>{% trans "Instance VNC Console" %}</h3>
|
||||
<p class='alert alert-info'>{% blocktrans %}If VNC console is not responding to keyboard input: click the grey status bar below.{% endblocktrans %}</p>
|
||||
<iframe src="{{ vnc_url }}" width="720" height="430"></iframe>
|
@ -8,122 +8,30 @@
|
||||
{% endblock page_header %}
|
||||
|
||||
{% block dash_main %}
|
||||
<ul id="instance_tabs">
|
||||
<li{% if show_tab == "overview" %} class="active"{% endif %}><a class="overview" href="?show=overview">{% trans "Overview" %}</a></li>
|
||||
<li{% if show_tab == "log" %} class="active"{% endif %}><a class="log" href="?show=log">{% trans "Log" %}</a></li>
|
||||
<li{% if show_tab == "vnc" %} class="active"{% endif %}><a class="vnc" href="?show=vnc">{% trans "VNC" %}</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="dash_block">
|
||||
{% if show_tab == "overview" %}
|
||||
<div id="overview" class="tab_wrapper">
|
||||
<ul>
|
||||
<li>
|
||||
<div class="status">
|
||||
<h4>{% trans "Status" %}</h4>
|
||||
<ul>
|
||||
<li><span>{% trans "Status:" %}</span> {{ instance.status }}</li>
|
||||
<li><span>{% trans "Instance Name:" %}</span> {{ instance.name }}</li>
|
||||
<li><span>{% trans "Instance ID:" %}</span> {{ instance.id }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<div class="specs">
|
||||
<h4>{% trans "Specs" %}</h4>
|
||||
<ul>
|
||||
<li><span>{% trans "RAM:" %}</span> {{ instance.full_flavor.ram|mbformat }}</li>
|
||||
<li><span>{% trans "VCPUs:" %}</span> {{ instance.full_flavor.vcpus }} {% trans "VCPU" %}</li>
|
||||
<li><span>{% trans "Disk:" %}</span> {{ instance.full_flavor.disk }}{% trans "GB" %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<div class="addresses">
|
||||
<h4>{% trans "IP Addresses" %}</h4>
|
||||
<ul>
|
||||
{% for network, ip_list in instance.addresses.items %}
|
||||
<li><span>{{ network|title }}:</span>
|
||||
{% for ip in ip_list %}
|
||||
{% if not forloop.last %}{{ ip.addr}}, {% else %}{{ip.addr}}{% endif %}
|
||||
{% endfor %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<div class="meta">
|
||||
<h4>{% trans "Meta" %}</h4>
|
||||
<ul>
|
||||
<li><span>{% trans "Key name:" %}</span> {{ instance.key_name }}</li>
|
||||
{% comment %} Security Groups aren't sent back from Nova anymore...
|
||||
<li><span>{% trans "Security Group(s):" %}</span> {% for group in instance.attrs.security_groups %}{{group}}, {% endfor %}</li>
|
||||
{% endcomment %}
|
||||
<li><span>{% trans "Image Name:" %}</span> {{ instance.image_name }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div>
|
||||
<h4>{% trans "Volumes" %}</h4>
|
||||
<ul>
|
||||
{% if volumes %}
|
||||
{% for volume in volumes %}
|
||||
<li>
|
||||
<span>{% trans "Volume:" %}</span>
|
||||
<a href="{% url horizon:nova:instances_and_volumes:volumes:detail volume.volumeId %}">
|
||||
{{ volume.volumeId }} ({{ volume.device }})
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li><span>{% trans "None Attached" %}</span></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="row-fluid">
|
||||
<div class="span12">
|
||||
{{ tab_group.render }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_tab == "log" %}
|
||||
<div id="log" class="tab_wrapper">
|
||||
<a class="view_full" target="_blank" href="{% url horizon:nova:instances_and_volumes:instances:console instance.id %}">{% trans "View Full Log" %}</a>
|
||||
<pre class="logs"></pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_tab == "vnc" %}
|
||||
<div id="vnc" class="tab_wrapper">
|
||||
<p class='alert alert-info'>If VNC console is not responding to keyboard input: click the grey status bar below.</p>
|
||||
<iframe src="{{vnc_url}}" width="720" height="430"></iframe>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
{{ block.super }}
|
||||
{# FIXME: This JavaScript should live with the rest of the JS #}
|
||||
{% if show_tab == "log" %}
|
||||
<script type="text/javascript" charset="utf-8">
|
||||
$(function() {
|
||||
function getLog() {
|
||||
$.get("{% url horizon:nova:instances_and_volumes:instances:console instance.id %}?length=25", function(data) {
|
||||
$("#log .logs").html(data);
|
||||
});
|
||||
if ($("#instance_details__log .logs").length) {
|
||||
$.get("{% url horizon:nova:instances_and_volumes:instances:console instance.id %}?length=25", function(data) {
|
||||
$("#instance_details__log .logs").html(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
getLog();
|
||||
|
||||
setInterval(function() {
|
||||
getLog();
|
||||
}, 3000);
|
||||
}, 10000); // This value has to stay under Nova's API rate limiting.
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
15
horizon/horizon/static/horizon/js/tabs.js
Normal file
15
horizon/horizon/static/horizon/js/tabs.js
Normal file
@ -0,0 +1,15 @@
|
||||
horizon.tabs = {};
|
||||
|
||||
horizon.tabs.load_tab = function (evt) {
|
||||
var $this = $(this),
|
||||
tab_id = $this.attr('data-target'),
|
||||
tab_pane = $(tab_id);
|
||||
tab_pane.append("<i class='icon icon-updating ajax-updating'></i> <span>loading...</span>");
|
||||
tab_pane.load("?tab=" + tab_id.replace('#', ''));
|
||||
$this.attr("data-loaded", "true");
|
||||
evt.preventDefault();
|
||||
};
|
||||
|
||||
horizon.addInitFunction(function () {
|
||||
$(document).on("click", ".ajax-tabs a[data-loaded='false']", horizon.tabs.load_tab);
|
||||
});
|
@ -14,7 +14,6 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import new
|
||||
from urlparse import urlparse
|
||||
@ -23,7 +22,6 @@ from urlparse import parse_qs
|
||||
from django import http
|
||||
from django import shortcuts
|
||||
from django.conf import settings
|
||||
from django.forms.util import flatatt
|
||||
from django.contrib import messages
|
||||
from django.core import urlresolvers
|
||||
from django.utils.functional import Promise
|
||||
@ -31,6 +29,7 @@ from django.utils.http import urlencode
|
||||
from django.utils.translation import string_concat, ugettext as _
|
||||
|
||||
from horizon import exceptions
|
||||
from horizon.utils import html
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
@ -39,17 +38,13 @@ LOG = logging.getLogger(__name__)
|
||||
ACTION_CSS_CLASSES = ("btn", "btn-small")
|
||||
|
||||
|
||||
class BaseAction(object):
|
||||
class BaseAction(html.HTMLElement):
|
||||
""" Common base class for all ``Action`` classes. """
|
||||
table = None
|
||||
handles_multiple = False
|
||||
requires_input = False
|
||||
preempt = False
|
||||
|
||||
def __init__(self):
|
||||
self.attrs = getattr(self, "attrs", {})
|
||||
self.classes = getattr(self, "classes", [])
|
||||
|
||||
def allowed(self, request, datum):
|
||||
""" Determine whether this action is allowed for the current request.
|
||||
|
||||
@ -74,22 +69,12 @@ class BaseAction(object):
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def attr_string(self):
|
||||
def get_default_classes(self):
|
||||
"""
|
||||
Returns a flattened string of HTML attributes based on the
|
||||
``attrs`` dict provided to the class.
|
||||
Returns a list of the default classes for the tab. Defaults to
|
||||
``["btn", "btn-small"]``.
|
||||
"""
|
||||
final_attrs = copy.copy(self.attrs)
|
||||
# Handle css class concatenation
|
||||
default = " ".join(getattr(settings,
|
||||
"ACTION_CSS_CLASSES",
|
||||
ACTION_CSS_CLASSES))
|
||||
defined = self.attrs.get('class', '')
|
||||
additional = " ".join(self.classes)
|
||||
final_classes = " ".join((defined, default, additional)).strip()
|
||||
final_attrs.update({'class': final_classes})
|
||||
return flatatt(final_attrs)
|
||||
return getattr(settings, "ACTION_CSS_CLASSES", ACTION_CSS_CLASSES)
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s: %s>" % (self.__class__.__name__, self.name)
|
||||
|
18
horizon/horizon/tabs/__init__.py
Normal file
18
horizon/horizon/tabs/__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 TabGroup, Tab
|
||||
from .views import TabView
|
336
horizon/horizon/tabs/base.py
Normal file
336
horizon/horizon/tabs/base.py
Normal file
@ -0,0 +1,336 @@
|
||||
# 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.template import TemplateSyntaxError
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.datastructures import SortedDict
|
||||
|
||||
from horizon.utils import html
|
||||
|
||||
SEPARATOR = "__"
|
||||
CSS_TAB_GROUP_CLASSES = ["nav", "nav-tabs", "ajax-tabs"]
|
||||
CSS_ACTIVE_TAB_CLASSES = ["active"]
|
||||
CSS_DISABLED_TAB_CLASSES = ["disabled"]
|
||||
|
||||
|
||||
class TabGroup(html.HTMLElement):
|
||||
"""
|
||||
A container class which knows how to manage and render
|
||||
:class:`~horizon.tabs.Tab` objects.
|
||||
|
||||
.. attribute:: slug
|
||||
|
||||
The URL slug and pseudo-unique identifier for this tab group.
|
||||
|
||||
.. attribute:: template_name
|
||||
|
||||
The name of the template which will be used to render this tab group.
|
||||
Default: ``"horizon/common/_tab_group.html"``
|
||||
|
||||
.. attribute:: param_name
|
||||
|
||||
The name of the GET request parameter which will be used when
|
||||
requesting specific tab data. Default: ``tab``.
|
||||
|
||||
.. attribute:: classes
|
||||
|
||||
A list of CSS classes which should be displayed on this tab group.
|
||||
|
||||
.. attribute:: attrs
|
||||
|
||||
A dictionary of HTML attributes which should be rendered into the
|
||||
markup for this tab group.
|
||||
|
||||
.. attribute:: selected
|
||||
|
||||
Read-only property which is set to the instance of the
|
||||
currently-selected tab if there is one, otherwise ``None``.
|
||||
|
||||
.. attribute:: active
|
||||
|
||||
Read-only property which is set to the value of the current active tab.
|
||||
This may not be the same as the value of ``selected`` if no
|
||||
specific tab was requested via the ``GET`` parameter.
|
||||
"""
|
||||
slug = None
|
||||
template_name = "horizon/common/_tab_group.html"
|
||||
param_name = 'tab'
|
||||
_selected = None
|
||||
_active = None
|
||||
|
||||
@property
|
||||
def selected(self):
|
||||
return self._selected
|
||||
|
||||
@property
|
||||
def active(self):
|
||||
return self._active
|
||||
|
||||
def __init__(self, request, **kwargs):
|
||||
super(TabGroup, self).__init__()
|
||||
if not hasattr(self, "tabs"):
|
||||
raise NotImplementedError('%s must declare a "tabs" attribute.'
|
||||
% self.__class__)
|
||||
self.request = request
|
||||
self.kwargs = kwargs
|
||||
tab_instances = []
|
||||
for tab in self.tabs:
|
||||
tab_instances.append((tab.slug, tab(self, request)))
|
||||
self._tabs = SortedDict(tab_instances)
|
||||
if not self._set_active_tab():
|
||||
self.tabs_not_available()
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s: %s>" % (self.__class__.__name__, self.slug)
|
||||
|
||||
def get_id(self):
|
||||
"""
|
||||
Returns the id for this tab group. Defaults to the value of the tab
|
||||
group's :attr:`horizon.tabs.Tab.slug`.
|
||||
"""
|
||||
return self.slug
|
||||
|
||||
def get_default_classes(self):
|
||||
"""
|
||||
Returns a list of the default classes for the tab group. Defaults to
|
||||
``["nav", "nav-tabs", "ajax-tabs"]``.
|
||||
"""
|
||||
default_classes = super(TabGroup, self).get_default_classes()
|
||||
default_classes.extend(CSS_TAB_GROUP_CLASSES)
|
||||
return default_classes
|
||||
|
||||
def tabs_not_available(self):
|
||||
"""
|
||||
In the event that no tabs are either allowed or enabled, this method
|
||||
is the fallback handler. By default it's a no-op, but it exists
|
||||
to make redirecting or raising exceptions possible for subclasses.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _set_active_tab(self):
|
||||
marked_active = None
|
||||
|
||||
# See if we have a selected tab via the GET parameter.
|
||||
tab = self.get_selected_tab()
|
||||
if tab:
|
||||
tab._active = True
|
||||
self._active = tab
|
||||
marked_active = tab
|
||||
|
||||
# Iterate through to mark them all accordingly.
|
||||
for tab in self._tabs.values():
|
||||
if tab._allowed and tab._enabled and not marked_active:
|
||||
tab._active = True
|
||||
self._active = tab
|
||||
marked_active = True
|
||||
elif tab == marked_active:
|
||||
continue
|
||||
else:
|
||||
tab._active = False
|
||||
|
||||
return marked_active
|
||||
|
||||
def render(self):
|
||||
""" Renders the HTML output for this tab group. """
|
||||
return render_to_string(self.template_name, {"tab_group": self})
|
||||
|
||||
def get_tabs(self):
|
||||
""" Returns a list of the allowed tabs for this tab group. """
|
||||
return filter(lambda tab: tab._allowed, self._tabs.values())
|
||||
|
||||
def get_tab(self, tab_name, allow_disabled=False):
|
||||
""" Returns a specific tab from this tab group.
|
||||
|
||||
If the tab is not allowed or not enabled this method returns ``None``.
|
||||
|
||||
If the tab is disabled but you wish to return it anyway, you can pass
|
||||
``True`` to the allow_disabled argument.
|
||||
"""
|
||||
tab = self._tabs.get(tab_name, None)
|
||||
if tab and tab._allowed and (tab._enabled or allow_disabled):
|
||||
return tab
|
||||
return None
|
||||
|
||||
def get_selected_tab(self):
|
||||
""" Returns the tab specific by the GET request parameter.
|
||||
|
||||
In the event that there is no GET request parameter, the value
|
||||
of the query parameter is invalid, or the tab is not allowed/enabled,
|
||||
the return value of this function is None.
|
||||
"""
|
||||
selected = self.request.GET.get(self.param_name, None)
|
||||
if selected:
|
||||
tab_group, tab_name = selected.split(SEPARATOR)
|
||||
if tab_group == self.get_id():
|
||||
self._selected = self.get_tab(tab_name)
|
||||
return self._selected
|
||||
|
||||
|
||||
class Tab(html.HTMLElement):
|
||||
"""
|
||||
A reusable interface for constructing a tab within a
|
||||
:class:`~horizon.tabs.TabGroup`.
|
||||
|
||||
.. attribute:: name
|
||||
|
||||
The display name for the tab which will be rendered as the text for
|
||||
the tab element in the HTML. Required.
|
||||
|
||||
.. attribute:: slug
|
||||
|
||||
The URL slug and id attribute for the tab. This should be unique for
|
||||
a given tab group. Required.
|
||||
|
||||
.. attribute:: preload
|
||||
|
||||
Determines whether the contents of the tab should be rendered into
|
||||
the page's HTML when the tab group is rendered, or whether it should
|
||||
be loaded dynamically when the tab is selected. Default: ``True``.
|
||||
|
||||
.. attribute:: classes
|
||||
|
||||
A list of CSS classes which should be displayed on this tab.
|
||||
|
||||
.. attribute:: attrs
|
||||
|
||||
A dictionary of HTML attributes which should be rendered into the
|
||||
markup for this tab.
|
||||
|
||||
.. attribute:: load
|
||||
|
||||
Read-only access to determine whether or not this tab's data should
|
||||
be loaded immediately.
|
||||
"""
|
||||
name = None
|
||||
slug = None
|
||||
preload = True
|
||||
_active = None
|
||||
|
||||
def __init__(self, tab_group, request):
|
||||
super(Tab, self).__init__()
|
||||
# Priority: constructor, class-defined, fallback
|
||||
if not self.name:
|
||||
raise ValueError("%s must have a name." % self.__class__.__name__)
|
||||
self.name = unicode(self.name) # Force unicode.
|
||||
if not self.slug:
|
||||
raise ValueError("%s must have a slug." % self.__class__.__name__)
|
||||
self.request = request
|
||||
self.tab_group = tab_group
|
||||
self._allowed = self.allowed(request)
|
||||
self._enabled = self.enabled(request)
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s: %s>" % (self.__class__.__name__, self.slug)
|
||||
|
||||
def is_active(self):
|
||||
""" Method to access whether or not this tab is the active tab. """
|
||||
if self._active is None:
|
||||
self.tab_group._set_active_tab()
|
||||
return self._active
|
||||
|
||||
@property
|
||||
def load(self):
|
||||
load_preloaded = self.preload or self.is_active()
|
||||
return load_preloaded and self._allowed and self._enabled
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Renders the tab to HTML using the :meth:`~horizon.tabs.Tab.get_data`
|
||||
method and the :meth:`~horizon.tabs.Tab.get_template_name` method.
|
||||
|
||||
If :attr:`~horizon.tabs.Tab.preload` is ``False`` and ``force_load``
|
||||
is not ``True``, or
|
||||
either :meth:`~horizon.tabs.Tab.allowed` or
|
||||
:meth:`~horizon.tabs.Tab.enabled` returns ``False`` this method will
|
||||
return an empty string.
|
||||
"""
|
||||
if not self.load:
|
||||
return ''
|
||||
try:
|
||||
context = self.get_context_data(self.request)
|
||||
except Exception as exc:
|
||||
raise TemplateSyntaxError(exc)
|
||||
return render_to_string(self.get_template_name(self.request), context)
|
||||
|
||||
def get_id(self):
|
||||
"""
|
||||
Returns the id for this tab. Defaults to
|
||||
``"{{ tab_group.slug }}__{{ tab.slug }}"``.
|
||||
"""
|
||||
return SEPARATOR.join([self.tab_group.slug, self.slug])
|
||||
|
||||
def get_default_classes(self):
|
||||
"""
|
||||
Returns a list of the default classes for the tab. Defaults to
|
||||
and empty list (``[]``), however additional classes may be added
|
||||
depending on the state of the tab as follows:
|
||||
|
||||
If the tab is the active tab for the tab group, in which
|
||||
the class ``"active"`` will be added.
|
||||
|
||||
If the tab is not enabled, the classes the class ``"disabled"``
|
||||
will be added.
|
||||
"""
|
||||
default_classes = super(Tab, self).get_default_classes()
|
||||
if self.is_active():
|
||||
default_classes.extend(CSS_ACTIVE_TAB_CLASSES)
|
||||
if not self._enabled:
|
||||
default_classes.extend(CSS_DISABLED_TAB_CLASSES)
|
||||
return default_classes
|
||||
|
||||
def get_template_name(self, request):
|
||||
"""
|
||||
Returns the name of the template to be used for rendering this tab.
|
||||
|
||||
By default it returns the value of the ``template_name`` attribute
|
||||
on the ``Tab`` class.
|
||||
"""
|
||||
if not hasattr(self, "template_name"):
|
||||
raise AttributeError("%s must have a template_name attribute or "
|
||||
"override the get_template_name method."
|
||||
% self.__class__.__name__)
|
||||
return self.template_name
|
||||
|
||||
def get_context_data(self, request):
|
||||
"""
|
||||
This method should return a dictionary of context data used to render
|
||||
the tab. Required.
|
||||
"""
|
||||
raise NotImplementedError("%s needs to define a get_context_data "
|
||||
"method." % self.__class__.__name__)
|
||||
|
||||
def enabled(self, request):
|
||||
"""
|
||||
Determines whether or not the tab should be accessible
|
||||
(e.g. be rendered into the HTML on load and respond to a click event).
|
||||
|
||||
If a tab returns ``False`` from ``enabled`` it will ignore the value
|
||||
of ``preload`` and only render the HTML of the tab after being clicked.
|
||||
|
||||
The default behavior is to return ``True`` for all cases.
|
||||
"""
|
||||
return True
|
||||
|
||||
def allowed(self, request):
|
||||
"""
|
||||
Determines whether or not the tab is displayed.
|
||||
|
||||
Tab instances can override this method to specify conditions under
|
||||
which this tab should not be shown at all by returning ``False``.
|
||||
|
||||
The default behavior is to return ``True`` for all cases.
|
||||
"""
|
||||
return True
|
42
horizon/horizon/tabs/views.py
Normal file
42
horizon/horizon/tabs/views.py
Normal file
@ -0,0 +1,42 @@
|
||||
from django import http
|
||||
from django.views import generic
|
||||
|
||||
from horizon import exceptions
|
||||
|
||||
|
||||
class TabView(generic.TemplateView):
|
||||
"""
|
||||
A generic class-based view for displaying a :class:`horizon.tabs.TabGroup`.
|
||||
|
||||
This view handles selecting specific tabs and deals with AJAX requests
|
||||
gracefully.
|
||||
|
||||
.. attribute:: tab_group_class
|
||||
|
||||
The only required attribute for ``TabView``. It should be a class which
|
||||
inherits from :class:`horizon.tabs.TabGroup`.
|
||||
"""
|
||||
tab_group_class = None
|
||||
|
||||
def __init__(self):
|
||||
if not self.tab_group_class:
|
||||
raise AttributeError("You must set the tab_group attribute on %s."
|
||||
% self.__class__.__name__)
|
||||
|
||||
def get_tabs(self, request, *args, **kwargs):
|
||||
return self.tab_group_class(request, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = self.get_context_data(**kwargs)
|
||||
try:
|
||||
tab_group = self.get_tabs(request, *args, **kwargs)
|
||||
context["tab_group"] = tab_group
|
||||
except:
|
||||
exceptions.handle(request)
|
||||
|
||||
if request.is_ajax():
|
||||
if tab_group.selected:
|
||||
return http.HttpResponse(tab_group.selected.render())
|
||||
else:
|
||||
return http.HttpResponse(tab_group.render())
|
||||
return self.render_to_response(context)
|
23
horizon/horizon/templates/horizon/common/_tab_group.html
Normal file
23
horizon/horizon/templates/horizon/common/_tab_group.html
Normal file
@ -0,0 +1,23 @@
|
||||
{% with tab_group.get_tabs as tabs %}
|
||||
{% if tabs %}
|
||||
|
||||
{# Tab Navigation #}
|
||||
<ul id="{{ tab_group.get_id }}" {{ tab_group.attr_string|safe }}>
|
||||
{% for tab in tabs %}
|
||||
<li {{ tab.attr_string|safe }}>
|
||||
<a href="?{{ tab_group.param_name}}={{ tab.get_id }}" data-toggle="tab" data-target="#{{ tab.get_id }}" data-loaded='{{ tab.load|yesno:"true,false" }}'>{{ tab.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{# Tab Content #}
|
||||
<div class="tab-content">
|
||||
{% for tab in tabs %}
|
||||
<div id="{{ tab.get_id }}" class="tab-pane{% if tab.is_active %} active{% endif %}">
|
||||
{{ tab.render }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
{% endwith %}
|
192
horizon/horizon/tests/tabs_tests.py
Normal file
192
horizon/horizon/tests/tabs_tests.py
Normal file
@ -0,0 +1,192 @@
|
||||
# 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 http
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import tabs as horizon_tabs
|
||||
from horizon import test
|
||||
|
||||
|
||||
class BaseTestTab(horizon_tabs.Tab):
|
||||
def get_context_data(self, request):
|
||||
return {"tab": self}
|
||||
|
||||
|
||||
class TabOne(BaseTestTab):
|
||||
slug = "tab_one"
|
||||
name = _("Tab One")
|
||||
template_name = "_tab.html"
|
||||
|
||||
|
||||
class TabDelayed(BaseTestTab):
|
||||
slug = "tab_delayed"
|
||||
name = _("Delayed Tab")
|
||||
template_name = "_tab.html"
|
||||
preload = False
|
||||
|
||||
|
||||
class TabDisabled(BaseTestTab):
|
||||
slug = "tab_disabled"
|
||||
name = _("Disabled Tab")
|
||||
template_name = "_tab.html"
|
||||
|
||||
def enabled(self, request):
|
||||
return False
|
||||
|
||||
|
||||
class TabDisallowed(BaseTestTab):
|
||||
slug = "tab_disallowed"
|
||||
name = _("Disallowed Tab")
|
||||
template_name = "_tab.html"
|
||||
|
||||
def allowed(self, request):
|
||||
return False
|
||||
|
||||
|
||||
class Group(horizon_tabs.TabGroup):
|
||||
slug = "tab_group"
|
||||
tabs = (TabOne, TabDelayed, TabDisabled, TabDisallowed)
|
||||
|
||||
def tabs_not_available(self):
|
||||
self._assert_tabs_not_available = True
|
||||
|
||||
|
||||
class TabTests(test.TestCase):
|
||||
def setUp(self):
|
||||
super(TabTests, self).setUp()
|
||||
|
||||
def test_tab_group_basics(self):
|
||||
tg = Group(self.request)
|
||||
|
||||
# Test tab instantiation/attachement to tab group, and get_tabs method
|
||||
tabs = tg.get_tabs()
|
||||
# "tab_disallowed" should NOT be in this list.
|
||||
self.assertQuerysetEqual(tabs, ['<TabOne: tab_one>',
|
||||
'<TabDelayed: tab_delayed>',
|
||||
'<TabDisabled: tab_disabled>'])
|
||||
# Test get_id
|
||||
self.assertEqual(tg.get_id(), "tab_group")
|
||||
# get_default_classes
|
||||
self.assertEqual(tg.get_default_classes(),
|
||||
horizon_tabs.base.CSS_TAB_GROUP_CLASSES)
|
||||
# Test get_tab
|
||||
self.assertEqual(tg.get_tab("tab_one").slug, "tab_one")
|
||||
|
||||
# Test selected is None w/o GET input
|
||||
self.assertEqual(tg.selected, None)
|
||||
|
||||
# Test get_selected_tab is None w/o GET input
|
||||
self.assertEqual(tg.get_selected_tab(), None)
|
||||
|
||||
def test_tab_group_active_tab(self):
|
||||
tg = Group(self.request)
|
||||
|
||||
# active tab w/o selected
|
||||
self.assertEqual(tg.active, tg.get_tabs()[0])
|
||||
|
||||
# active tab w/ selected
|
||||
self.request.GET['tab'] = "tab_group__tab_delayed"
|
||||
tg = Group(self.request)
|
||||
self.assertEqual(tg.active, tg.get_tab('tab_delayed'))
|
||||
|
||||
# active tab w/ invalid selected
|
||||
self.request.GET['tab'] = "tab_group__tab_invalid"
|
||||
tg = Group(self.request)
|
||||
self.assertEqual(tg.active, tg.get_tabs()[0])
|
||||
|
||||
# active tab w/ disallowed selected
|
||||
self.request.GET['tab'] = "tab_group__tab_disallowed"
|
||||
tg = Group(self.request)
|
||||
self.assertEqual(tg.active, tg.get_tabs()[0])
|
||||
|
||||
# active tab w/ disabled selected
|
||||
self.request.GET['tab'] = "tab_group__tab_disabled"
|
||||
tg = Group(self.request)
|
||||
self.assertEqual(tg.active, tg.get_tabs()[0])
|
||||
|
||||
def test_tab_basics(self):
|
||||
tg = Group(self.request)
|
||||
tab_one = tg.get_tab("tab_one")
|
||||
tab_delayed = tg.get_tab("tab_delayed")
|
||||
tab_disabled = tg.get_tab("tab_disabled", allow_disabled=True)
|
||||
|
||||
# Disallowed tab isn't even returned
|
||||
tab_disallowed = tg.get_tab("tab_disallowed")
|
||||
self.assertEqual(tab_disallowed, None)
|
||||
|
||||
# get_id
|
||||
self.assertEqual(tab_one.get_id(), "tab_group__tab_one")
|
||||
|
||||
# get_default_classes
|
||||
self.assertEqual(tab_one.get_default_classes(),
|
||||
horizon_tabs.base.CSS_ACTIVE_TAB_CLASSES)
|
||||
self.assertEqual(tab_disabled.get_default_classes(),
|
||||
horizon_tabs.base.CSS_DISABLED_TAB_CLASSES)
|
||||
|
||||
# load, allowed, enabled
|
||||
self.assertTrue(tab_one.load)
|
||||
self.assertFalse(tab_delayed.load)
|
||||
self.assertFalse(tab_disabled.load)
|
||||
self.request.GET['tab'] = tab_delayed.get_id()
|
||||
tg = Group(self.request)
|
||||
tab_delayed = tg.get_tab("tab_delayed")
|
||||
self.assertTrue(tab_delayed.load)
|
||||
|
||||
# is_active
|
||||
self.request.GET['tab'] = ""
|
||||
tg = Group(self.request)
|
||||
tab_one = tg.get_tab("tab_one")
|
||||
tab_delayed = tg.get_tab("tab_delayed")
|
||||
self.assertTrue(tab_one.is_active())
|
||||
self.assertFalse(tab_delayed.is_active())
|
||||
|
||||
self.request.GET['tab'] = tab_delayed.get_id()
|
||||
tg = Group(self.request)
|
||||
tab_one = tg.get_tab("tab_one")
|
||||
tab_delayed = tg.get_tab("tab_delayed")
|
||||
self.assertFalse(tab_one.is_active())
|
||||
self.assertTrue(tab_delayed.is_active())
|
||||
|
||||
def test_rendering(self):
|
||||
tg = Group(self.request)
|
||||
tab_one = tg.get_tab("tab_one")
|
||||
tab_delayed = tg.get_tab("tab_delayed")
|
||||
tab_disabled = tg.get_tab("tab_disabled", allow_disabled=True)
|
||||
|
||||
# tab group
|
||||
output = tg.render()
|
||||
res = http.HttpResponse(output.strip())
|
||||
self.assertContains(res, "<li", 3)
|
||||
|
||||
# tab
|
||||
output = tab_one.render()
|
||||
self.assertEqual(output.strip(), tab_one.name)
|
||||
|
||||
# disabled tab
|
||||
output = tab_disabled.render()
|
||||
self.assertEqual(output.strip(), "")
|
||||
|
||||
# preload false
|
||||
output = tab_delayed.render()
|
||||
self.assertEqual(output.strip(), "")
|
||||
|
||||
# preload false w/ active
|
||||
self.request.GET['tab'] = tab_delayed.get_id()
|
||||
tg = Group(self.request)
|
||||
tab_delayed = tg.get_tab("tab_delayed")
|
||||
output = tab_delayed.render()
|
||||
self.assertEqual(output.strip(), tab_delayed.name)
|
1
horizon/horizon/tests/templates/_tab.html
Normal file
1
horizon/horizon/tests/templates/_tab.html
Normal file
@ -0,0 +1 @@
|
||||
{{ tab.name }}
|
32
horizon/horizon/utils/html.py
Normal file
32
horizon/horizon/utils/html.py
Normal file
@ -0,0 +1,32 @@
|
||||
import copy
|
||||
|
||||
from django.forms.util import flatatt
|
||||
|
||||
|
||||
class HTMLElement(object):
|
||||
""" A generic base class that gracefully handles html-style attributes. """
|
||||
def __init__(self):
|
||||
self.attrs = getattr(self, "attrs", {})
|
||||
self.classes = getattr(self, "classes", [])
|
||||
|
||||
def get_default_classes(self):
|
||||
"""
|
||||
Returns a list of default classes which should be combined with any
|
||||
other declared classes.
|
||||
"""
|
||||
return []
|
||||
|
||||
@property
|
||||
def attr_string(self):
|
||||
"""
|
||||
Returns a flattened string of HTML attributes based on the
|
||||
``attrs`` dict provided to the class.
|
||||
"""
|
||||
final_attrs = copy.copy(self.attrs)
|
||||
# Handle css class concatenation
|
||||
default = " ".join(self.get_default_classes())
|
||||
defined = self.attrs.get('class', '')
|
||||
additional = " ".join(getattr(self, "classes", []))
|
||||
final_classes = " ".join((defined, default, additional)).strip()
|
||||
final_attrs.update({'class': final_classes})
|
||||
return flatatt(final_attrs)
|
@ -29,7 +29,7 @@ class APIView(generic.TemplateView):
|
||||
the :func:`horizon.exceptions.handle` error handler if not otherwise
|
||||
caught.
|
||||
"""
|
||||
def get_data(request, context, *args, **kwargs):
|
||||
def get_data(self, request, context, *args, **kwargs):
|
||||
"""
|
||||
This method should handle any necessary API calls, update the
|
||||
context object, and return the context object at the end.
|
||||
|
@ -775,9 +775,14 @@ tr.terminated {
|
||||
margin-bottom: -5px;
|
||||
}
|
||||
|
||||
a.view_full {
|
||||
float: right;
|
||||
margin: 10px 10px 0 0;
|
||||
#main_content .nav-tabs {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#main_content .tab-content {
|
||||
border: 1px solid #ddd;
|
||||
border-top: 0 none;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.tab_wrapper {
|
||||
@ -885,3 +890,15 @@ form .error {
|
||||
height: 15px;
|
||||
margin: -8px 0 8px;
|
||||
}
|
||||
|
||||
#main_content .row-fluid {
|
||||
margin: 10px 0 20px;
|
||||
}
|
||||
|
||||
#main_content .row-fluid:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.header_rule {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
@ -12,6 +12,7 @@
|
||||
|
||||
{% comment %} Horizon-specific JS {% endcomment %}
|
||||
<script src='{{ STATIC_URL }}horizon/js/horizon.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}horizon/js/tabs.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}dashboard/js/plugins.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}dashboard/js/tables.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}dashboard/js/modals.js' type='text/javascript' charset='utf-8'></script>
|
||||
|
Loading…
Reference in New Issue
Block a user