diff --git a/doc/source/topics/customizing.rst b/doc/source/topics/customizing.rst
index 0db67f5a4..e75b47579 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 e3cca0322..b8977fe29 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 c1afaa0f0..1f3e16719 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'{}
',
+ 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 dcfb85518..18c2e6d3b 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 c892399bd..d2db72a4b 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 54ca7c0c5..d99c9f823 100644
--- a/horizon/templates/horizon/common/_form_field.html
+++ b/horizon/templates/horizon/common/_form_field.html
@@ -3,27 +3,7 @@