Merge "Horizon Checkboxes are now themeable."

This commit is contained in:
Jenkins 2016-03-03 17:09:48 +00:00 committed by Gerrit Code Review
commit 721c963b9d
24 changed files with 288 additions and 87 deletions

View File

@ -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
------------------------------

View File

@ -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",

View File

@ -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'<div class="themable-checkbox">{}<label for="{}"></label></div>',
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'<div class="themable-checkbox">{}<label{}>' +
u'<span>{}</span></label></div>',
self.tag(attrs), label_for, self.choice_label
)
class ThemableCheckboxFieldRenderer(widgets.CheckboxFieldRenderer):
choice_input_class = ThemableCheckboxChoiceInput
class ThemableCheckboxSelectMultiple(widgets.CheckboxSelectMultiple):
renderer = ThemableCheckboxFieldRenderer
_empty_value = []

View File

@ -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('<input type="checkbox" class="table-row-multi-select">');
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.

View File

@ -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)),

View File

@ -3,27 +3,7 @@
<div class="form-group{% if field.errors %} has-error{% endif %} {{ field.css_classes }}">
{% if field|is_checkbox %}
<div class="{{ classes.single_value }}">
<div class="checkbox">
{% if field.auto_id %}
<label
{% if field.field.required and form.required_css_class %}
class="{{ form.required_css_class }}"
{% endif %}>
{{ field }}
<span>{{ field.label }}</span>
{% if field.field.required %}{% include "horizon/common/_form_field_required.html" %}{% endif %}
{% if field.help_text %}
<span class="help-icon" data-toggle="tooltip"
data-placement="top" title="{{ field.help_text|safe }}">
<span class="fa fa-question-circle"></span>
</span>
{% endif %}
</label>
{% endif %}
{% for error in field.errors %}
<span class="help-block {{ form.error_css_class }}">{{ error }}</span>
{% endfor %}
</div>
{% include 'horizon/common/fields/_themable_checkbox.html' %}
</div>
{% elif field|is_radio %}
{% if field.auto_id %}

View File

@ -3,7 +3,13 @@
<div class="form-group{% if field.errors %} has-error{% endif %} {{ field.css_classes }}">
<label class="control-label col-sm-3 {% if field.field.required %}{{ form.required_css_class }}{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label>
<div class="col-sm-9 {{ classes.value }} {{ field|wrapper_classes }}">
{{ 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 %}
<span class="help-block alert alert-danger {{ form.error_css_class }}">{{ error }}</span>
{% empty %}

View File

@ -0,0 +1,24 @@
<div class="themable-checkbox">
{% if field.auto_id %}
{{ field }}
<label
{% if field.field.required and form.required_css_class %}
class="{{ form.required_css_class }}"
{% endif %}
for="{{ field.auto_id }}">
{% if not is_vertical %}
<span>{{ field.label }}</span>
{% endif %}
{% if field.field.required %}{% include "horizon/common/_form_field_required.html" %}{% endif %}
{% if field.help_text %}
<span class="help-icon" data-toggle="tooltip"
data-placement="top" title="{{ field.help_text|safe }}">
<span class="fa fa-question-circle"></span>
</span>
{% endif %}
</label>
{% endif %}
{% for error in field.errors %}
<span class="help-block {{ form.error_css_class }}">{{ error }}</span>
{% endfor %}
</div>

View File

@ -13,6 +13,7 @@
import django.forms
from django import template as django_template
register = django_template.Library()

View File

@ -9,6 +9,7 @@
window.WEBROOT = '{{ WEBROOT }}';
</script>
<script src='{{ STATIC_URL }}horizon/lib/jquery/jquery.js' type='text/javascript' charset="utf-8"></script>
<script src='{{ STATIC_URL }}horizon/lib/jquery-ui/ui/jquery-ui.js' type='text/javascript' charset="utf-8"></script>
<script src='{{ STATIC_URL }}horizon/lib/jquery/jquery-migrate.js' type='text/javascript' charset="utf-8"></script>
<script src="{{ STATIC_URL }}horizon/lib/jquery/jquery.tablesorter.js"></script>
<script src="{{ STATIC_URL }}horizon/lib/angular/angular.js" type="text/javascript" charset="utf-8"></script>
@ -123,4 +124,4 @@
{% endblock %}
</body>
</html>
</html>

View File

@ -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(),

View File

@ -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):

View File

@ -527,6 +527,10 @@
<input type="checkbox"> Checkbox
</label>
</div>
<div class="themable-checkbox">
<input type="checkbox" id="themable-checkbox">
<label for="themable-checkbox" translate>Checkbox</label>
</div>
</div>
</div>
<div class="form-group">

View File

@ -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'

View File

@ -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):

View File

@ -1617,11 +1617,11 @@ class InstanceTests(helpers.TestCase):
else:
self.assertNotContains(res, boot_from_image_field_label)
checked_label = '<label for="id_network_0"><input checked="checked"'
checked_box = '<input checked="checked" id="id_network_0"'
if only_one_network:
self.assertContains(res, checked_label)
self.assertContains(res, checked_box)
else:
self.assertNotContains(res, checked_label)
self.assertNotContains(res, checked_box)
disk_config_field_label = 'Disk Partition'
if disk_config:

View File

@ -551,12 +551,13 @@ class SetAccessControlsAction(workflows.Action):
label=_("Confirm Admin Password"),
required=False,
widget=forms.PasswordInput(render_value=False))
groups = forms.MultipleChoiceField(label=_("Security Groups"),
required=False,
initial=["default"],
widget=forms.CheckboxSelectMultiple(),
help_text=_("Launch instance in these "
"security groups."))
groups = forms.MultipleChoiceField(
label=_("Security Groups"),
required=False,
initial=["default"],
widget=forms.ThemableCheckboxSelectMultiple(),
help_text=_("Launch instance in these "
"security groups."))
class Meta(object):
name = _("Access & Security")
@ -701,14 +702,15 @@ class PostCreationStep(workflows.Step):
class SetNetworkAction(workflows.Action):
network = forms.MultipleChoiceField(label=_("Networks"),
widget=forms.CheckboxSelectMultiple(),
error_messages={
'required': _(
"At least one network must"
" be specified.")},
help_text=_("Launch instance with"
" these networks"))
network = forms.MultipleChoiceField(
label=_("Networks"),
widget=forms.ThemableCheckboxSelectMultiple(),
error_messages={
'required': _(
"At least one network must"
" be specified.")},
help_text=_("Launch instance with"
" these networks"))
if api.neutron.is_port_profiles_supported():
widget = None
else:

View File

@ -0,0 +1,38 @@
@import '/horizon/lib/font-awesome/scss/variables';
@import '/horizon/lib/font-awesome/scss/mixins';
//
// Checkboxes
// This will ONLY work when the label's 'for' attribute
// shares the input[type=checkbox]'s 'id' value
// --------------------------------------------------
.themable-checkbox {
// Hide the real checkbox
input[type=checkbox] {
display:none;
// The checkbox - Unchecked
& + label {
margin-bottom: 0; // remove the Bootstrap margin
&:before {
@include fa-icon();
content: $fa-var-square-o;
width: 1em;
vertical-align: middle;
}
& > span {
padding-left: $padding-small-vertical;
vertical-align: middle;
}
}
// The checkbox - Checked
&:checked + label:before {
content: $fa-var-check-square-o;
}
}
}

View File

@ -17,6 +17,7 @@
// Dashboard Components
@import "components/bar_charts";
@import "components/charts";
@import "components/checkboxes";
@import "components/datepicker";
@import "components/forms";
@import "components/help_panel";

View File

@ -9,6 +9,8 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import re
import six
from selenium.webdriver.common import by
@ -94,17 +96,31 @@ class CheckBoxMixin(object):
if self.is_marked():
self.element.click()
@property
def name(self):
"""Themable checkboxes use a <label> with font-awesome icon as a
control element while the <input> widget is hidden. Still the name
needs to be extracted from <label>. Attribute "for" is used for that,
since it mirrors <input> "id" which in turn is derived from <input>'s
name.
"""
for_attribute = self.element.get_attribute('for')
indirect_name = re.search(r'id_(.*)', for_attribute)
return (indirect_name and indirect_name.group(1)) or None
class CheckBoxFormFieldRegion(BaseFormFieldRegion, CheckBoxMixin):
class CheckBoxFormFieldRegion(CheckBoxMixin, BaseFormFieldRegion):
"""Checkbox field."""
_element_locator_str_suffix = 'label > input[type=checkbox]'
_element_locator_str_suffix = \
'.themable-checkbox input[type=checkbox] + label'
class ProjectPageCheckBoxFormFieldRegion(BaseFormFieldRegion, CheckBoxMixin):
class ProjectPageCheckBoxFormFieldRegion(CheckBoxMixin, BaseFormFieldRegion):
"""Checkbox field for Project-page."""
_element_locator_str_suffix = 'div > input[type=checkbox]'
_element_locator_str_suffix = \
'div > .themable-checkbox input[type=checkbox] + label'
class ChooseFileFormFieldRegion(BaseFormFieldRegion):

View File

@ -24,7 +24,10 @@ class RowRegion(baseregion.BaseRegion):
"""Classic table row."""
_cell_locator = (by.By.CSS_SELECTOR, 'td.%s' % NORMAL_COLUMN_CLASS)
_row_checkbox_locator = (by.By.CSS_SELECTOR, 'td > input')
_row_checkbox_locator = (
by.By.CSS_SELECTOR,
'td .themable-checkbox [type="checkbox"] + label'
)
def __init__(self, driver, conf, src_elem, column_names):
self.column_names = column_names

View File

@ -36,6 +36,10 @@ form label {
}
}
.help-icon {
color: $gray;
}
// Note (hurgleburgler) : the combination of display: table-cell, width: 100%
// and max-width has strange consequences in CSS. This is required to make sure
// that the width does not stretch beyond 100%. Please see the answer to

View File

@ -1,6 +1,7 @@
@import "icons";
@import "components/context_selection";
@import "components/messages";
@import "components/checkboxes";
@import "components/hamburger";
@import "components/magic_search";
@import "components/navbar";

View File

@ -0,0 +1,29 @@
//
// Custom Material Checkboxes
// --------------------------------------------------
@import "/horizon/lib/mdi/scss/icons";
.themable-checkbox {
input[type=checkbox] {
// The checkbox - Unchecked
& + label {
@extend .mdi-checkbox-blank-outline;
&:before {
@extend .mdi;
font-size: $font-size-h5;
}
}
// The checkbox - Checked
&:checked + label {
@extend .mdi-checkbox-marked;
&:before {
color: $brand-primary;
}
}
}
}