From fca0b641a733ff3f1b3697ca04ebffb65e6c20fa Mon Sep 17 00:00:00 2001 From: Gabriel Hurley Date: Mon, 27 Feb 2012 15:40:07 -0800 Subject: [PATCH] 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 --- docs/source/index.rst | 1 + docs/source/ref/tabs.rst | 37 ++ .../instances_and_volumes/instances/tabs.py | 74 ++++ .../instances_and_volumes/instances/tests.py | 21 +- .../instances_and_volumes/instances/views.py | 93 +++-- .../instances/_detail_log.html | 9 + .../instances/_detail_overview.html | 65 ++++ .../instances/_detail_vnc.html | 5 + .../instances/detail.html | 110 +----- horizon/horizon/static/horizon/js/tabs.js | 15 + horizon/horizon/tables/actions.py | 27 +- horizon/horizon/tabs/__init__.py | 18 + horizon/horizon/tabs/base.py | 336 ++++++++++++++++++ horizon/horizon/tabs/views.py | 42 +++ .../templates/horizon/common/_tab_group.html | 23 ++ horizon/horizon/tests/tabs_tests.py | 192 ++++++++++ horizon/horizon/tests/templates/_tab.html | 1 + horizon/horizon/utils/html.py | 32 ++ horizon/horizon/views/base.py | 2 +- .../dashboard/static/dashboard/css/style.css | 23 +- .../dashboard/templates/_scripts.html | 1 + 21 files changed, 939 insertions(+), 188 deletions(-) create mode 100644 docs/source/ref/tabs.rst create mode 100644 horizon/horizon/dashboards/nova/instances_and_volumes/instances/tabs.py create mode 100644 horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_detail_log.html create mode 100644 horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_detail_overview.html create mode 100644 horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_detail_vnc.html create mode 100644 horizon/horizon/static/horizon/js/tabs.js create mode 100644 horizon/horizon/tabs/__init__.py create mode 100644 horizon/horizon/tabs/base.py create mode 100644 horizon/horizon/tabs/views.py create mode 100644 horizon/horizon/templates/horizon/common/_tab_group.html create mode 100644 horizon/horizon/tests/tabs_tests.py create mode 100644 horizon/horizon/tests/templates/_tab.html create mode 100644 horizon/horizon/utils/html.py diff --git a/docs/source/index.rst b/docs/source/index.rst index e8946c26e..ae0d6f368 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -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 diff --git a/docs/source/ref/tabs.rst b/docs/source/ref/tabs.rst new file mode 100644 index 000000000..9da824ab2 --- /dev/null +++ b/docs/source/ref/tabs.rst @@ -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: diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/instances/tabs.py b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/tabs.py new file mode 100644 index 000000000..ea114c7ea --- /dev/null +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/tabs.py @@ -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) diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/instances/tests.py b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/tests.py index 3810c64ad..6bb8d7025 100644 --- a/horizon/horizon/dashboards/nova/instances_and_volumes/instances/tests.py +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/tests.py @@ -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() diff --git a/horizon/horizon/dashboards/nova/instances_and_volumes/instances/views.py b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/views.py index b53b514fb..001e98e63 100644 --- a/horizon/horizon/dashboards/nova/instances_and_volumes/instances/views.py +++ b/horizon/horizon/dashboards/nova/instances_and_volumes/instances/views.py @@ -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) diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_detail_log.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_detail_log.html new file mode 100644 index 000000000..ac3a8b7b5 --- /dev/null +++ b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_detail_log.html @@ -0,0 +1,9 @@ +{% load i18n %} +
+

Instance Console Log

+

+ {% url horizon:nova:instances_and_volumes:instances:console instance.id as console_url %} + {% trans "View Full Log" %} +

+
+
{{ console_log }}
diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_detail_overview.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_detail_overview.html new file mode 100644 index 000000000..22460c77f --- /dev/null +++ b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_detail_overview.html @@ -0,0 +1,65 @@ +{% load i18n sizeformat %} + +

{% trans "Instance Overview" %}

+ +
+

{% trans "Status" %}

+
+ +
+ +
+

{% trans "Specs" %}

+
+ +
+ +
+

{% trans "IP Addresses" %}

+
+ +
+ +
+

{% trans "Meta" %}

+
+ +
+ +
+

{% trans "Volumes" %}

+
+ +
diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_detail_vnc.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_detail_vnc.html new file mode 100644 index 000000000..c91609a7b --- /dev/null +++ b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_detail_vnc.html @@ -0,0 +1,5 @@ +{% load i18n %} + +

{% trans "Instance VNC Console" %}

+

{% blocktrans %}If VNC console is not responding to keyboard input: click the grey status bar below.{% endblocktrans %}

+ diff --git a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/detail.html b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/detail.html index 2cc990cc8..a8eb48638 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/detail.html +++ b/horizon/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/detail.html @@ -8,122 +8,30 @@ {% endblock page_header %} {% block dash_main %} - - -
- {% if show_tab == "overview" %} -
-
    -
  • -
    -

    {% trans "Status" %}

    -
      -
    • {% trans "Status:" %} {{ instance.status }}
    • -
    • {% trans "Instance Name:" %} {{ instance.name }}
    • -
    • {% trans "Instance ID:" %} {{ instance.id }}
    • -
    -
    -
  • - -
  • -
    -

    {% trans "Specs" %}

    -
      -
    • {% trans "RAM:" %} {{ instance.full_flavor.ram|mbformat }}
    • -
    • {% trans "VCPUs:" %} {{ instance.full_flavor.vcpus }} {% trans "VCPU" %}
    • -
    • {% trans "Disk:" %} {{ instance.full_flavor.disk }}{% trans "GB" %}
    • -
    -
    -
  • - -
  • -
    -

    {% trans "IP Addresses" %}

    -
      - {% for network, ip_list in instance.addresses.items %} -
    • {{ network|title }}: - {% for ip in ip_list %} - {% if not forloop.last %}{{ ip.addr}}, {% else %}{{ip.addr}}{% endif %} - {% endfor %} -
    • - {% endfor %} -
    -
    -
  • - -
  • -
    -

    {% trans "Meta" %}

    -
      -
    • {% trans "Key name:" %} {{ instance.key_name }}
    • - {% comment %} Security Groups aren't sent back from Nova anymore... -
    • {% trans "Security Group(s):" %} {% for group in instance.attrs.security_groups %}{{group}}, {% endfor %}
    • - {% endcomment %} -
    • {% trans "Image Name:" %} {{ instance.image_name }}
    • -
    -
    -
  • -
  • -
    -

    {% trans "Volumes" %}

    - -
    -
  • -
+
+
+ {{ tab_group.render }}
- {% endif %} - - {% if show_tab == "log" %} - - {% endif %} - - {% if show_tab == "vnc" %} -
-

If VNC console is not responding to keyboard input: click the grey status bar below.

- -
- {% endif %} -
{% endblock %} {% block js %} {{ block.super }} {# FIXME: This JavaScript should live with the rest of the JS #} - {% if show_tab == "log" %} - {% endif %} {% endblock %} diff --git a/horizon/horizon/static/horizon/js/tabs.js b/horizon/horizon/static/horizon/js/tabs.js new file mode 100644 index 000000000..b0ac08c14 --- /dev/null +++ b/horizon/horizon/static/horizon/js/tabs.js @@ -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(" loading..."); + 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); +}); diff --git a/horizon/horizon/tables/actions.py b/horizon/horizon/tables/actions.py index 38fccd62b..319e25082 100644 --- a/horizon/horizon/tables/actions.py +++ b/horizon/horizon/tables/actions.py @@ -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) diff --git a/horizon/horizon/tabs/__init__.py b/horizon/horizon/tabs/__init__.py new file mode 100644 index 000000000..de62cf8ac --- /dev/null +++ b/horizon/horizon/tabs/__init__.py @@ -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 diff --git a/horizon/horizon/tabs/base.py b/horizon/horizon/tabs/base.py new file mode 100644 index 000000000..5d7be93f5 --- /dev/null +++ b/horizon/horizon/tabs/base.py @@ -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 diff --git a/horizon/horizon/tabs/views.py b/horizon/horizon/tabs/views.py new file mode 100644 index 000000000..dfe81bce0 --- /dev/null +++ b/horizon/horizon/tabs/views.py @@ -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) diff --git a/horizon/horizon/templates/horizon/common/_tab_group.html b/horizon/horizon/templates/horizon/common/_tab_group.html new file mode 100644 index 000000000..89ca70ca7 --- /dev/null +++ b/horizon/horizon/templates/horizon/common/_tab_group.html @@ -0,0 +1,23 @@ +{% with tab_group.get_tabs as tabs %} +{% if tabs %} + + {# Tab Navigation #} + + + {# Tab Content #} +
+ {% for tab in tabs %} +
+ {{ tab.render }} +
+ {% endfor %} +
+ +{% endif %} +{% endwith %} diff --git a/horizon/horizon/tests/tabs_tests.py b/horizon/horizon/tests/tabs_tests.py new file mode 100644 index 000000000..87121e01b --- /dev/null +++ b/horizon/horizon/tests/tabs_tests.py @@ -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, ['', + '', + '']) + # 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, " +