Horizon selects are now themable: Volumes

Horizon was using a standard select input.  Unfortunately, this
type of input is only customizable to a small extent.

I/We have created a new input type for forms to use that is a bootstrap
dropdown.  This will allow bootstrap themes to affect selects the same
way they do for check boxes and radio buttons and the like.

Material's inputs are a bit special, so custom css was added to keep
style parity with Bootswatch's Paper.

co-authored-by: brian.tully@hp.com
co-authored-by: hurgleburgler@gmail.com

ThemableSelect widget template has been modified so that shadow select
element does not contain any classes (which are usually used for
customizing appearance - since it's hidden, it doesn't need
that). Given that we could match legacy select element as 'div >
select.form-control' with no possibility that SelectFormFieldRegion
(legacy select widget region) will bind a hidden part of
ThemableSelect widget and intercept all commands send to
ThemableSelectFormFieldRegion. ThemableSelectFormFieldRegion was
modified to still use legacy widget wrapper for getting 'name' and
'value' properties, while doing all other things on its own, not using
Selenium wrapper for <select>.

Co-Authored-By: Timur Sufiev <tsufiev@mirantis.com>

Change-Id: Ie921b3adc2e1d3388d3c2aa1f76afe3af6ceb87b
Partially-implements: blueprint horizon-theme-css-reorg
This commit is contained in:
Diana Whitten 2016-03-07 17:02:53 -07:00
parent 9d7cff5d96
commit 20c17d28d3
14 changed files with 325 additions and 50 deletions

View File

@ -35,6 +35,9 @@ 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.fields import ThemableChoiceField # noqa
from horizon.forms.fields import ThemableDynamicChoiceField # noqa
from horizon.forms.fields import ThemableSelectWidget # noqa
from horizon.forms.views import ModalFormMixin # noqa
from horizon.forms.views import ModalFormView # noqa
@ -49,6 +52,9 @@ __all__ = [
"DynamicChoiceField",
"ThemableCheckboxInput",
"ThemableCheckboxSelectMultiple",
"ThemableChoiceField",
"ThemableDynamicChoiceField",
"ThemableSelectWidget",
"IPField",
"IPv4",
"IPv6",

View File

@ -12,6 +12,7 @@
# License for the specific language governing permissions and limitations
# under the License.
import itertools
import re
import netaddr
@ -23,9 +24,12 @@ from django.core import urlresolvers
from django.forms import fields
from django.forms.utils import flatatt # noqa
from django.forms import widgets
from django.template import Context # noqa
from django.template.loader import get_template # noqa
from django.utils.encoding import force_text
from django.utils.functional import Promise # noqa
from django.utils import html
from django.utils.safestring import mark_safe # noqa
from django.utils.translation import ugettext_lazy as _
ip_allowed_symbols_re = re.compile(r'^[a-fA-F0-9:/\.]+$')
@ -164,7 +168,7 @@ class SelectWidget(widgets.Select):
....
....
widget=forms.SelectWidget( attrs={'class': 'switchable',
widget=forms.ThemableSelect( attrs={'class': 'switchable',
'data-slug': 'source'},
transform_html_attrs=get_title )
@ -187,26 +191,84 @@ class SelectWidget(widgets.Select):
other_html = (u' selected="selected"'
if option_value in selected_choices else '')
if callable(self.transform_html_attrs):
html_attrs = self.transform_html_attrs(option_label)
other_html += flatatt(html_attrs)
other_html += self.transform_option_html_attrs(option_label)
data_attr_html = self.get_data_attrs(option_label)
if data_attr_html:
other_html += ' ' + data_attr_html
option_label = self.transform_option_label(option_label)
return u'<option value="%s"%s>%s</option>' % (
html.escape(option_value), other_html, option_label)
def get_data_attrs(self, option_label):
other_html = []
if not isinstance(option_label, (six.string_types, Promise)):
for data_attr in self.data_attrs:
data_value = html.conditional_escape(
force_text(getattr(option_label,
data_attr, "")))
other_html += ' data-%s="%s"' % (data_attr, data_value)
other_html.append('data-%s="%s"' % (data_attr, data_value))
return ' '.join(other_html)
if callable(self.transform):
option_label = self.transform(option_label)
def transform_option_label(self, option_label):
if (not isinstance(option_label, (six.string_types, Promise)) and
callable(self.transform)):
option_label = self.transform(option_label)
return html.conditional_escape(force_text(option_label))
return u'<option value="%s"%s>%s</option>' % (
html.escape(option_value), other_html,
html.conditional_escape(force_text(option_label)))
def transform_option_html_attrs(self, option_label):
if not callable(self.transform_html_attrs):
return ''
return flatatt(self.transform_html_attrs(option_label))
class DynamicSelectWidget(widgets.Select):
class ThemableSelectWidget(SelectWidget):
"""Bootstrap base select field widget."""
def render(self, name, value, attrs=None, choices=()):
# NOTE(woodnt): Currently the "attrs" contents are being added to the
# select that's hidden. It's unclear whether this is the
# desired behavior. In some cases, the attribute should
# remain solely on the now-hidden select. But in others
# if it should live on the bootstrap button (visible)
# or both.
new_choices = []
for opt_value, opt_label in itertools.chain(self.choices, choices):
other_html = self.transform_option_html_attrs(opt_label)
data_attr_html = self.get_data_attrs(opt_label)
if data_attr_html:
other_html += ' ' + data_attr_html
opt_label = self.transform_option_label(opt_label)
if other_html:
new_choices.append((opt_value, opt_label, other_html))
else:
new_choices.append((opt_value, opt_label))
initial_value = value
if value is None and new_choices:
initial_value = new_choices[0][1]
attrs = self.build_attrs(attrs)
id = attrs.pop('id', 'id_%s' % name)
template = get_template('horizon/common/fields/_themable_select.html')
context = Context({
'name': name,
'options': new_choices,
'id': id,
'value': value,
'initial_value': initial_value,
'select_attrs': attrs,
})
return template.render(context)
class DynamicSelectWidget(SelectWidget):
"""A subclass of the ``Select`` widget which renders extra attributes for
use in callbacks to handle dynamic changes to the available choices.
"""
@ -231,6 +293,15 @@ class DynamicSelectWidget(widgets.Select):
return self.add_item_link
class ThemableDynamicSelectWidget(ThemableSelectWidget, DynamicSelectWidget):
pass
class ThemableChoiceField(fields.ChoiceField):
"""Bootstrap based select field."""
widget = ThemableSelectWidget
class DynamicChoiceField(fields.ChoiceField):
"""A subclass of ``ChoiceField`` with additional properties that make
dynamically updating its elements easier.
@ -251,6 +322,10 @@ class DynamicChoiceField(fields.ChoiceField):
self.widget.add_item_link_args = add_item_link_args
class ThemableDynamicChoiceField(DynamicChoiceField):
widget = ThemableDynamicSelectWidget
class DynamicTypedChoiceField(DynamicChoiceField, fields.TypedChoiceField):
"""Simple mix of ``DynamicChoiceField`` and ``TypedChoiceField``."""
pass

View File

@ -24,7 +24,7 @@
</div>
{% elif filter.filter_type == 'server' %}
<div class="table_search">
{% with name=filter.get_param_name|add:'_field' options=filter.get_select_options value=filter.filter_field %}
{% with name=filter.get_param_name|add:'_field' options=filter.get_select_options stand_alone=1 value=filter.filter_field %}
{% include 'horizon/common/fields/_themable_select.html' %}
{% endwith %}
<input class="form-control" value="{{ filter.filter_string|default:'' }}" type="text" name="{{ filter.get_param_name }}" />

View File

@ -1,7 +1,7 @@
{% load horizon %}
{% minifyspace %}
<div class="themable-select dropdown" xmlns="http://www.w3.org/1999/html">
<div class="themable-select dropdown {% if not stand_alone %} form-control{% endif %}" xmlns="http://www.w3.org/1999/html">
<button type="button" class="btn btn-default dropdown-toggle"
data-toggle="dropdown"{% if value %}
title="{{ value }}" {% endif %}
@ -38,9 +38,7 @@
name="{{ name }}"
{% endif %}
{% for k,v in select_attrs.items %}
{% if k == 'class' %}
class="form-control {{ v }}"
{% else %}
{% if k != 'class' %}
{{ k|safe }}="{{ v }}"
{% endif %}
{% endfor %}

View File

@ -4,9 +4,7 @@
{% block modal-body %}
{% if show_attach %}
<h3>{% trans "Attach To Instance" %}</h3>
<fieldset>
{% include "horizon/common/_form_fields.html" %}
</fieldset>
{% endif %}
{% endblock %}

View File

@ -80,28 +80,29 @@ class CreateForm(forms.SelfHandlingForm):
description = forms.CharField(max_length=255, widget=forms.Textarea(
attrs={'rows': 4}),
label=_("Description"), required=False)
volume_source_type = forms.ChoiceField(label=_("Volume Source"),
required=False,
widget=forms.Select(attrs={
'class': 'switchable',
'data-slug': 'source'}))
volume_source_type = forms.ChoiceField(
label=_("Volume Source"),
required=False,
widget=forms.ThemableSelectWidget(attrs={
'class': 'switchable',
'data-slug': 'source'}))
snapshot_source = forms.ChoiceField(
label=_("Use snapshot as a source"),
widget=forms.SelectWidget(
widget=forms.ThemableSelectWidget(
attrs={'class': 'snapshot-selector'},
data_attrs=('size', 'name'),
transform=lambda x: "%s (%s GiB)" % (x.name, x.size)),
required=False)
image_source = forms.ChoiceField(
label=_("Use image as a source"),
widget=forms.SelectWidget(
widget=forms.ThemableSelectWidget(
attrs={'class': 'image-selector'},
data_attrs=('size', 'name', 'min_disk'),
transform=lambda x: "%s (%s)" % (x.name, filesizeformat(x.bytes))),
required=False)
volume_source = forms.ChoiceField(
label=_("Use a volume as source"),
widget=forms.SelectWidget(
widget=forms.ThemableSelectWidget(
attrs={'class': 'image-selector'},
data_attrs=('size', 'name'),
transform=lambda x: "%s (%s GiB)" % (x.name, x.size)),
@ -109,7 +110,7 @@ class CreateForm(forms.SelfHandlingForm):
type = forms.ChoiceField(
label=_("Type"),
required=False,
widget=forms.Select(
widget=forms.ThemableSelectWidget(
attrs={'class': 'switched',
'data-switch-on': 'source',
'data-source-no_source_type': _('Type'),
@ -118,7 +119,7 @@ class CreateForm(forms.SelfHandlingForm):
availability_zone = forms.ChoiceField(
label=_("Availability Zone"),
required=False,
widget=forms.Select(
widget=forms.ThemableSelectWidget(
attrs={'class': 'switched',
'data-switch-on': 'source',
'data-source-no_source_type': _('Availability Zone'),
@ -419,9 +420,9 @@ class CreateForm(forms.SelfHandlingForm):
class AttachForm(forms.SelfHandlingForm):
instance = forms.ChoiceField(label=_("Attach to Instance"),
help_text=_("Select an instance to "
"attach to."))
instance = forms.ThemableChoiceField(label=_("Attach to Instance"),
help_text=_("Select an instance to "
"attach to."))
device = forms.CharField(label=_("Device Name"),
widget=forms.TextInput(attrs={'placeholder':
@ -645,7 +646,7 @@ class UploadToImageForm(forms.SelfHandlingForm):
attrs={'readonly': 'readonly'}))
image_name = forms.CharField(max_length=255, label=_('Image Name'))
disk_format = forms.ChoiceField(label=_('Disk Format'),
widget=forms.Select(),
widget=forms.ThemableSelectWidget(),
required=False)
force = forms.BooleanField(
label=pgettext_lazy("Force upload volume in in-use status to image",
@ -751,11 +752,11 @@ class RetypeForm(forms.SelfHandlingForm):
name = forms.CharField(label=_('Volume Name'),
widget=forms.TextInput(
attrs={'readonly': 'readonly'}))
volume_type = forms.ChoiceField(label=_('Type'))
volume_type = forms.ThemableChoiceField(label=_('Type'))
MIGRATION_POLICY_CHOICES = [('never', _('Never')),
('on-demand', _('On Demand'))]
migration_policy = forms.ChoiceField(label=_('Migration Policy'),
widget=forms.Select(),
widget=forms.ThemableSelectWidget(),
choices=(MIGRATION_POLICY_CHOICES),
initial='never',
required=False)

View File

@ -37,17 +37,19 @@
&.btn-primary {
color: $brand-primary;
}
&.btn-danger {
color: $brand-danger;
}
&.btn-warning {
color: $brand-warning;
}
&.btn-info {
color: $brand-info;
}
@include dropdown-button('default', $dropdown-link-color, $dropdown-link-hover-bg);
@include dropdown-button('primary', $btn-primary-bg, $btn-primary-bg, $btn-primary-color);
@include dropdown-button('info', $btn-info-bg, $btn-info-bg, $btn-info-color);

View File

@ -3,15 +3,72 @@
// ---------------------------------
.themable-select {
.dropdown-title {
text-align: left;
position: relative;
&:after {
content: ' ';
}
.dropdown-title {
text-align: left;
&:after {
content: ' ';
}
}
select {
display: none;
&,
&.form-control {
display: none;
}
}
}
// The dropdowns within forms should look like all other
// form elements
.form-group .themable-select {
padding: 0;
&,
&.open {
.btn {
box-shadow: none;
border: none;
height: 100%;
}
.dropdown-menu {
background-color: $input-bg;
background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214
border-color: $input-border;
width: 100%;
li > a {
@include text-overflow();
color: $input-color;
&:hover {
color: $dropdown-link-hover-color;
cursor: pointer;
}
}
}
}
}
// For vertical forms, we'll want the button to take on the entire width.
// think old-school launch-instance.
form:not(.form-inline) .form-group .themable-select {
width: 100%;
.dropdown-toggle {
@extend .btn-block;
.fa-caret-down {
left: 0;
}
}
.dropdown-title {
@include text-overflow();
width: calc(100% - 1em);
float: left;
}
}

View File

@ -228,7 +228,7 @@ class VolumesPage(basepage.BaseNavigationPage):
class VolumeAttachForm(forms.BaseFormRegion):
_attach_to_instance_selector = (By.CSS_SELECTOR, 'select[name="instance"]')
_attach_to_instance_selector = (By.CSS_SELECTOR, 'div > .themable-select')
_attachments_table_selector = (By.CSS_SELECTOR, 'table[id="attachments"]')
_detach_template = 'tr[data-display="Volume {0} on instance {1}"] button'
@ -239,7 +239,9 @@ class VolumeAttachForm(forms.BaseFormRegion):
@property
def instance_selector(self):
src_elem = self._get_element(*self._attach_to_instance_selector)
return forms.SelectFormFieldRegion(self.driver, self.conf, src_elem)
return forms.ThemableSelectFormFieldRegion(
self.driver, self.conf, src_elem=src_elem,
strict_options_match=False)
def detach(self, volume, instance):
detach_button = self.attachments_table.find_element(
@ -248,9 +250,5 @@ class VolumeAttachForm(forms.BaseFormRegion):
return forms.BaseFormRegion(self.driver, self.conf)
def attach_instance(self, instance_name):
instance = filter(lambda x: x.startswith(instance_name),
self.instance_selector.options.values())
if not instance:
raise AttributeError("Unable to select {0}".format(instance_name))
self.instance_selector.text = instance[0]
self.instance_selector.text = instance_name
self.submit()

View File

@ -36,7 +36,7 @@ class FieldFactory(baseregion.BaseRegion):
field_cls._element_locator_str_suffix))
elements = super(FieldFactory, self)._get_elements(*locator)
for element in elements:
yield field_cls(self.driver, self.conf, element)
yield field_cls(self.driver, self.conf, src_elem=element)
@classmethod
def register_field_cls(cls, field_class, base_classes=None):
@ -174,7 +174,7 @@ class IntegerFormFieldRegion(BaseFormFieldRegion):
class SelectFormFieldRegion(BaseFormFieldRegion):
"""Select box field."""
_element_locator_str_suffix = 'div > select'
_element_locator_str_suffix = 'div > select.form-control'
def is_displayed(self):
return self.element._el.is_displayed()
@ -218,6 +218,67 @@ class SelectFormFieldRegion(BaseFormFieldRegion):
self.element.select_by_value(value)
class ThemableSelectFormFieldRegion(BaseFormFieldRegion):
"""Select box field."""
_element_locator_str_suffix = 'div > .themable-select'
_raw_select_locator = (by.By.CSS_SELECTOR, 'select')
_selected_label_locator = (by.By.CSS_SELECTOR, '.dropdown-title')
_dropdown_menu_locator = (by.By.CSS_SELECTOR, 'ul.dropdown-menu > li > a')
def __init__(self, driver, conf, strict_options_match=True, **kwargs):
super(ThemableSelectFormFieldRegion, self).__init__(
driver, conf, **kwargs)
self.strict_options_match = strict_options_match
@property
def hidden_element(self):
elem = self._get_element(*self._raw_select_locator)
return SelectFormFieldRegion(self.driver, self.conf, src_elem=elem)
@property
def name(self):
return self.hidden_element.name
@property
def text(self):
return self._get_element(*self._selected_label_locator).text.strip()
@property
def value(self):
return self.hidden_element.value
@property
def options(self):
return self._get_elements(*self._dropdown_menu_locator)
@text.setter
def text(self, text):
if text != self.text:
self.src_elem.click()
for option in self.options:
if self.strict_options_match:
match = text == option.text.strip()
else:
match = option.text.startswith(text)
if match:
option.click()
return
raise ValueError('Widget "%s" does have an option with text "%s"'
% (self.name, text))
@value.setter
def value(self, value):
if value != self.value:
self.src_elem.click()
for option in self.options:
if value == option.get_attribute('data-select-value'):
option.click()
return
raise ValueError('Widget "%s" does have an option with value "%s"'
% (self.name, value))
class BaseFormRegion(baseregion.BaseRegion):
"""Base class for forms."""

View File

@ -6,6 +6,7 @@
@import "components/navbar";
@import "components/pie_charts";
@import "components/quota";
@import "components/selects";
@import "components/sidebar";
@import "components/tables";
@import "components/table_actions";

View File

@ -0,0 +1,3 @@
.form-group .themable-select .dropdown-menu {
@extend .dropdown-menu-right;
}

View File

@ -7,6 +7,7 @@
@import "components/magic_search";
@import "components/messages";
@import "components/navbar";
@import "components/selects";
@import "components/sidebar";
.login .splash-logo {

View File

@ -0,0 +1,74 @@
// The dropdowns within forms should look like all other
// form elements, so this mimics Bootswatch's Paper's
// bootswatch.scss form-group styles
.form-group .themable-select {
box-shadow: none;
border-radius: 0;
border: none;
&,
&.open {
.btn {
&,
&:hover,
&.active,
&:active,
&:focus {
background: transparent;
padding: 0;
border: none;
border-radius: 0;
-webkit-appearance: none;
@include box-shadow(inset 0 -1px 0 $table-border-color);
font-size: 16px;
&:focus {
@include box-shadow(inset 0 -2px 0 $brand-primary);
}
&[disabled],
&[readonly] {
@include box-shadow(none);
border-bottom: 1px dotted $table-border-color;
}
&.input {
&-sm {
font-size: $font-size-small;
}
&-lg {
font-size: $font-size-large;
}
}
}
}
}
&.open .dropdown-menu {
background-color: $dropdown-bg;
}
}
@mixin material-select-states($state, $color) {
.form-group.#{$state} .themable-select {
&,
&.open {
.btn {
&,
&:hover,
&.active,
&:active,
&:focus {
border-bottom: none;
@include box-shadow(inset 0 -2px 0 $color);
}
}
}
}
}
@include material-select-states('has-warning', $brand-warning);
@include material-select-states('has-error', $brand-danger);
@include material-select-states('has-success', $brand-success);