diff --git a/doc/source/topics/customizing.rst b/doc/source/topics/customizing.rst index 0db67f5a4c..e75b475790 100644 --- a/doc/source/topics/customizing.rst +++ b/doc/source/topics/customizing.rst @@ -209,6 +209,7 @@ full use of the Bootstrap theme architecture. * Login_ * Tabs_ * Alerts_ +* Checkboxes_ Step 1 ------ @@ -321,6 +322,13 @@ Alerts Alerts use the basic Bootstrap brand colors. See **Colors** section of your variables file for specifics. +Checkboxes +---------- + +Horizon uses icon fonts to represent checkboxes. In order to customize +this, you simply need to override the standard scss. For an example of +this, see themes/material/static/horizon/components/_checkboxes.scss + Bootswatch and Material Design ------------------------------ diff --git a/horizon/forms/__init__.py b/horizon/forms/__init__.py index e3cca03221..b8977fe29e 100644 --- a/horizon/forms/__init__.py +++ b/horizon/forms/__init__.py @@ -33,6 +33,8 @@ from horizon.forms.fields import IPv4 # noqa from horizon.forms.fields import IPv6 # noqa from horizon.forms.fields import MultiIPField # noqa from horizon.forms.fields import SelectWidget # noqa +from horizon.forms.fields import ThemableCheckboxInput # noqa +from horizon.forms.fields import ThemableCheckboxSelectMultiple # noqa from horizon.forms.views import ModalFormMixin # noqa from horizon.forms.views import ModalFormView # noqa @@ -45,6 +47,8 @@ __all__ = [ "ModalFormMixin", "DynamicTypedChoiceField", "DynamicChoiceField", + "ThemableCheckboxInput", + "ThemableCheckboxSelectMultiple", "IPField", "IPv4", "IPv6", diff --git a/horizon/forms/fields.py b/horizon/forms/fields.py index c1afaa0f02..1f3e167196 100644 --- a/horizon/forms/fields.py +++ b/horizon/forms/fields.py @@ -16,6 +16,7 @@ import re import netaddr import six +import uuid from django.core.exceptions import ValidationError # noqa from django.core import urlresolvers @@ -253,3 +254,44 @@ class DynamicChoiceField(fields.ChoiceField): class DynamicTypedChoiceField(DynamicChoiceField, fields.TypedChoiceField): """Simple mix of ``DynamicChoiceField`` and ``TypedChoiceField``.""" pass + + +class ThemableCheckboxInput(widgets.CheckboxInput): + """A subclass of the ``Checkbox`` widget which renders extra markup to + allow a custom checkbox experience. + """ + def render(self, name, value, attrs=None): + label_for = attrs.get('id', '') + + if not label_for: + attrs['id'] = uuid.uuid4() + label_for = attrs['id'] + + return html.format_html( + u'
{}
', + super(ThemableCheckboxInput, self).render(name, value, attrs), + label_for + ) + + +class ThemableCheckboxChoiceInput(widgets.CheckboxChoiceInput): + def render(self, name=None, value=None, attrs=None, choices=()): + if self.id_for_label: + label_for = html.format_html(' for="{}"', self.id_for_label) + else: + label_for = '' + attrs = dict(self.attrs, **attrs) if attrs else self.attrs + return html.format_html( + u'
{}' + + u'{}
', + self.tag(attrs), label_for, self.choice_label + ) + + +class ThemableCheckboxFieldRenderer(widgets.CheckboxFieldRenderer): + choice_input_class = ThemableCheckboxChoiceInput + + +class ThemableCheckboxSelectMultiple(widgets.CheckboxSelectMultiple): + renderer = ThemableCheckboxFieldRenderer + _empty_value = [] diff --git a/horizon/static/horizon/js/horizon.tables.js b/horizon/static/horizon/js/horizon.tables.js index dcfb855184..18c2e6d3b4 100644 --- a/horizon/static/horizon/js/horizon.tables.js +++ b/horizon/static/horizon/js/horizon.tables.js @@ -89,9 +89,12 @@ horizon.datatables = { // Only replace row if the html content has changed if($new_row.html() !== $row.html()) { - if($row.find('.table-row-multi-select:checkbox').is(':checked')) { + + // Directly accessing the checked property of the element + // is MUCH faster than using jQuery's helper method + if($row.find('.table-row-multi-select')[0].checked) { // Preserve the checkbox if it's already clicked - $new_row.find('.table-row-multi-select:checkbox').prop('checked', true); + $new_row.find('.table-row-multi-select').prop('checked', true); } $row.replaceWith($new_row); // Reset tablesorter's data cache. @@ -151,37 +154,50 @@ horizon.datatables = { }); }, - validate_button: function ($form) { + validate_button: function ($form, disable_button) { // Enable or disable table batch action buttons based on row selection. $form = $form || $(".table_wrapper > form"); $form.each(function () { var $this = $(this); - var checkboxes = $this.find(".table-row-multi-select:checkbox"); - var action_buttons = $this.find('.table_actions button[data-batch-action="true"]'); - action_buttons.toggleClass("disabled", !checkboxes.filter(":checked").length); + var $action_buttons = $this.find('.table_actions button[data-batch-action="true"]'); + if (typeof disable_button == undefined) { + disable_button = $this.find(".table-row-multi-select").filter(":checked").length > 0; + } + $action_buttons.toggleClass("disabled", disable_button); }); }, initialize_checkboxes_behavior: function() { // Bind the "select all" checkbox action. - $('div.table_wrapper, #modal_wrapper').on('click', 'table thead .multi_select_column .table-row-multi-select:checkbox', function() { - var $this = $(this), - $table = $this.closest('table'), - is_checked = $this.prop('checked'), - checkboxes = $table.find('tbody .table-row-multi-select:visible:checkbox'); - checkboxes.prop('checked', is_checked); - }); - // Change "select all" checkbox behavior while any checkbox is checked/unchecked. - $("div.table_wrapper, #modal_wrapper").on("click", 'table tbody .table-row-multi-select:checkbox', function () { - var $table = $(this).closest('table'); - var $multi_select_checkbox = $table.find('thead .multi_select_column .table-row-multi-select:checkbox'); - var any_unchecked = $table.find("tbody .table-row-multi-select:checkbox").not(":checked"); - $multi_select_checkbox.prop('checked', any_unchecked.length === 0); - }); - // Enable/disable table batch action buttons when row selection changes. - $("div.table_wrapper, #modal_wrapper").on("click", '.table-row-multi-select:checkbox', function () { - horizon.datatables.validate_button($(this).closest("form")); - }); + $('.table_wrapper, #modal_wrapper') + .on('change', '.table-row-multi-select', function() { + var $this = $(this); + var $table = $this.closest('table'); + var is_checked = $this.prop('checked'); + + if ($this.hasClass('multi-select-header')) { + + // Only select / deselect the visible rows + $table.find('tbody tr:visible .table-row-multi-select') + .prop('checked', is_checked); + + } else { + + // Find the master checkbox + var $multi_select_checkbox = $table.find('.multi-select-header'); + + // Determine if there are any unchecked checkboxes in the table + var $checkboxes = $table.find('tbody .table-row-multi-select'); + var not_checked = $checkboxes.not(':checked').length; + is_checked = $checkboxes.length != not_checked; + + // If there are none, then check the master checkbox + $multi_select_checkbox.prop('checked', not_checked == 0); + } + + // Pass in whether it should be visible, no point in doing this twice + horizon.datatables.validate_button($this.closest('form'), !is_checked); + }); }, initialize_table_tooltips: function() { @@ -229,7 +245,7 @@ horizon.datatables.confirm = function (action) { var actions_div = $(action).closest("div"); if(actions_div.hasClass("table_actions") || actions_div.hasClass("table_actions_menu")) { // One or more checkboxes selected - $("#"+closest_table_id+" tr[data-display]").has(".table-row-multi-select:checkbox:checked").each(function() { + $("#"+closest_table_id+" tr[data-display]").has(".table-row-multi-select:checked").each(function() { name_array.push(" \"" + $(this).attr("data-display") + "\""); }); name_array.join(", "); @@ -480,11 +496,30 @@ horizon.datatables.set_table_sorting = function (parent) { }); }; -horizon.datatables.add_table_checkboxes = function(parent) { - $(parent).find('table thead .multi_select_column').each(function(index, thead) { - if (!$(thead).find('.table-row-multi-select:checkbox').length && - $(thead).parents('table').find('tbody .table-row-multi-select:checkbox').length) { - $(thead).append(''); +horizon.datatables.add_table_checkboxes = function($parent) { + $($parent).find('table thead .multi_select_column').each(function() { + var $thead = $(this); + if (!$thead.find('.table-row-multi-select').length && + $thead.parents('table').find('tbody .table-row-multi-select').length) { + + // Build up the themable checkbox + var $container = $(document.createElement('div')) + .addClass('themable-checkbox'); + + // Create the input checkbox + var $input = $(document.createElement('input')) + .attr('type', 'checkbox') + .addClass('table-row-multi-select multi-select-header') + .uniqueId() + .appendTo($container); + + // Create the label + $(document.createElement('label')) + .attr('for', $input.attr('id')) + .appendTo($container); + + // Append to the thead last, for speed + $thead.append($container); } }); }; @@ -576,10 +611,11 @@ horizon.addInitFunction(horizon.datatables.init = function() { horizon.datatables.initialize_table_tooltips(); // Trigger run-once setup scripts for tables. - horizon.datatables.add_table_checkboxes($('body')); - horizon.datatables.set_table_sorting($('body')); - horizon.datatables.set_table_query_filter($('body')); - horizon.datatables.set_table_fixed_filter($('body')); + var $body = $('body'); + horizon.datatables.add_table_checkboxes($body); + horizon.datatables.set_table_sorting($body); + horizon.datatables.set_table_query_filter($body); + horizon.datatables.set_table_fixed_filter($body); horizon.datatables.disable_actions_on_submit(); // Also apply on tables in modal views. diff --git a/horizon/tables/base.py b/horizon/tables/base.py index c892399bd9..d2db72a4b6 100644 --- a/horizon/tables/base.py +++ b/horizon/tables/base.py @@ -37,6 +37,7 @@ import six from horizon import conf from horizon import exceptions +from horizon.forms import ThemableCheckboxInput from horizon import messages from horizon.tables.actions import FilterAction # noqa from horizon.tables.actions import LinkAction # noqa @@ -672,7 +673,7 @@ class Cell(html.HTMLElement): if column.auto == "multi_select": data = "" if row.can_be_selected(datum): - widget = forms.CheckboxInput(check_test=lambda value: False) + widget = ThemableCheckboxInput(check_test=lambda value: False) # Convert value to string to avoid accidental type conversion data = widget.render('object_ids', six.text_type(table.get_object_id(datum)), diff --git a/horizon/templates/horizon/common/_form_field.html b/horizon/templates/horizon/common/_form_field.html index 54ca7c0c5f..d99c9f8233 100644 --- a/horizon/templates/horizon/common/_form_field.html +++ b/horizon/templates/horizon/common/_form_field.html @@ -3,27 +3,7 @@
{% if field|is_checkbox %}
-
- {% if field.auto_id %} - - {% endif %} - {% for error in field.errors %} - {{ error }} - {% endfor %} -
+ {% include 'horizon/common/fields/_themable_checkbox.html' %}
{% elif field|is_radio %} {% if field.auto_id %} diff --git a/horizon/templates/horizon/common/_horizontal_field.html b/horizon/templates/horizon/common/_horizontal_field.html index 8712ba8600..798ac5cfa0 100644 --- a/horizon/templates/horizon/common/_horizontal_field.html +++ b/horizon/templates/horizon/common/_horizontal_field.html @@ -3,7 +3,13 @@
- {{ field|add_bootstrap_class }} + {% if field|is_checkbox %} + {% with is_vertical=1 %} + {% include 'horizon/common/fields/_themable_checkbox.html' %} + {% endwith %} + {% else %} + {{ field|add_bootstrap_class }} + {% endif %} {% for error in field.errors %} {{ error }} {% empty %} diff --git a/horizon/templates/horizon/common/fields/_themable_checkbox.html b/horizon/templates/horizon/common/fields/_themable_checkbox.html new file mode 100644 index 0000000000..ebb49be895 --- /dev/null +++ b/horizon/templates/horizon/common/fields/_themable_checkbox.html @@ -0,0 +1,24 @@ +
+ {% if field.auto_id %} + {{ field }} + + {% endif %} + {% for error in field.errors %} + {{ error }} + {% endfor %} +
diff --git a/horizon/templatetags/form_helpers.py b/horizon/templatetags/form_helpers.py index 2f1ab5e8e6..2de183ad4b 100644 --- a/horizon/templatetags/form_helpers.py +++ b/horizon/templatetags/form_helpers.py @@ -13,6 +13,7 @@ import django.forms from django import template as django_template + register = django_template.Library() diff --git a/horizon/test/templates/base.html b/horizon/test/templates/base.html index 345e931701..0bd9da54ba 100644 --- a/horizon/test/templates/base.html +++ b/horizon/test/templates/base.html @@ -9,6 +9,7 @@ window.WEBROOT = '{{ WEBROOT }}'; + @@ -123,4 +124,4 @@ {% endblock %} - \ No newline at end of file + diff --git a/horizon/test/tests/base.py b/horizon/test/tests/base.py index 5c64e8debb..12b85e0a1b 100644 --- a/horizon/test/tests/base.py +++ b/horizon/test/tests/base.py @@ -338,10 +338,10 @@ class GetUserHomeTests(BaseHorizonTests): conf.HORIZON_CONFIG._setup() def test_using_callable(self): - def fancy_user_fnc(user): + def themable_user_fnc(user): return user.username.upper() - settings.HORIZON_CONFIG['user_home'] = fancy_user_fnc + settings.HORIZON_CONFIG['user_home'] = themable_user_fnc conf.HORIZON_CONFIG._setup() self.assertEqual(self.test_user.username.upper(), diff --git a/horizon/test/tests/selenium_tests.py b/horizon/test/tests/selenium_tests.py index 46410c4693..b81b40844d 100644 --- a/horizon/test/tests/selenium_tests.py +++ b/horizon/test/tests/selenium_tests.py @@ -37,7 +37,7 @@ class LazyLoadedTabsTests(test.SeleniumTestCase): table_selector = 'div.tab-content > div#{0} > div.table_wrapper'.format( tab_id) button_selector = 'button#lazy_puppies__action_delete' - checkbox_selector = 'td.multi_select_column > input[type=checkbox]' + checkbox_selector = 'td.multi_select_column input[type=checkbox]' select_all_selector = 'th.multi_select_column input[type=checkbox]' def setUp(self): diff --git a/openstack_dashboard/contrib/developer/static/dashboard/developer/theme-preview/theme-preview.html b/openstack_dashboard/contrib/developer/static/dashboard/developer/theme-preview/theme-preview.html index 048d2f393e..43d8121c9f 100644 --- a/openstack_dashboard/contrib/developer/static/dashboard/developer/theme-preview/theme-preview.html +++ b/openstack_dashboard/contrib/developer/static/dashboard/developer/theme-preview/theme-preview.html @@ -527,6 +527,10 @@ Checkbox
+
+ + +
diff --git a/openstack_dashboard/dashboards/project/firewalls/forms.py b/openstack_dashboard/dashboards/project/firewalls/forms.py index 81cb0612fb..f05f63499b 100644 --- a/openstack_dashboard/dashboards/project/firewalls/forms.py +++ b/openstack_dashboard/dashboards/project/firewalls/forms.py @@ -338,7 +338,7 @@ class AddRouterToFirewall(RouterInsertionFormBase): router_ids = forms.MultipleChoiceField( label=_("Add Routers"), required=False, - widget=forms.CheckboxSelectMultiple(), + widget=forms.ThemableCheckboxSelectMultiple(), help_text=_("Add selected router(s) to the firewall.")) failure_url = 'horizon:project:firewalls:index' @@ -363,7 +363,7 @@ class RemoveRouterFromFirewall(RouterInsertionFormBase): router_ids = forms.MultipleChoiceField( label=_("Associated Routers"), required=False, - widget=forms.CheckboxSelectMultiple(), + widget=forms.ThemableCheckboxSelectMultiple(), help_text=_("Unselect the router(s) to be removed from firewall.")) failure_url = 'horizon:project:firewalls:index' diff --git a/openstack_dashboard/dashboards/project/firewalls/workflows.py b/openstack_dashboard/dashboards/project/firewalls/workflows.py index 8dd5654317..217d6b3b0b 100644 --- a/openstack_dashboard/dashboards/project/firewalls/workflows.py +++ b/openstack_dashboard/dashboards/project/firewalls/workflows.py @@ -164,7 +164,7 @@ class SelectRulesAction(workflows.Action): rule = forms.MultipleChoiceField( label=_("Rules"), required=False, - widget=forms.CheckboxSelectMultiple(), + widget=forms.ThemableCheckboxSelectMultiple(), help_text=_("Create a policy with selected rules.")) class Meta(object): @@ -206,7 +206,7 @@ class SelectRoutersAction(workflows.Action): router = forms.MultipleChoiceField( label=_("Routers"), required=False, - widget=forms.CheckboxSelectMultiple(), + widget=forms.ThemableCheckboxSelectMultiple(), help_text=_("Create a firewall with selected routers.")) class Meta(object): diff --git a/openstack_dashboard/dashboards/project/instances/tests.py b/openstack_dashboard/dashboards/project/instances/tests.py index 234243ceb4..7eeb2cddd4 100644 --- a/openstack_dashboard/dashboards/project/instances/tests.py +++ b/openstack_dashboard/dashboards/project/instances/tests.py @@ -1617,11 +1617,11 @@ class InstanceTests(helpers.TestCase): else: self.assertNotContains(res, boot_from_image_field_label) - checked_label = '