Merge "Horizon selects are now themable: Volumes"

This commit is contained in:
Jenkins 2016-06-02 12:12:45 +00:00 committed by Gerrit Code Review
commit 4e384db0cf
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);