Skip to content

Commit

Permalink
Merge pull request #21 from jeromelebleu/bootstrap5
Browse files Browse the repository at this point in the history
Add a mixin for Bootstrap v5
  • Loading branch information
stephrdev committed Sep 10, 2021
2 parents 1973616 + c3538ea commit 33edadc
Show file tree
Hide file tree
Showing 23 changed files with 244 additions and 63 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
Changelog
=========

Unreleased
----------

* Rename Bootstrap mixin to ``Bootstrap4TapeformMixin``, an alias is kept for
backward compatibility but it will be deprecated and removed at some time
* Remove 'small' CSS class from the help text in Boostrap mixin
* Use 'form-field-' as prefix for container CSS class instead of the value
returned by ``get_field_container_css_class``
* Add a new ``Bootstrap5TapeformMixin`` mixin for Bootstrap v5
* Add 'form-group' CSS class to checkbox container in Bootstrap mixin


1.0.1 - 2021-04-28
------------------

Expand Down
8 changes: 6 additions & 2 deletions docs/contrib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ Extra, optional, features of `django-tapeforms`.
Bootstrap mixin
---------------

You can use the :py:class:`tapeforms.contrib.bootstrap.BootstrapTapeformMixin`
to render forms with a Bootstrap 4 compatible HTML layout / css classes.
You can use the :py:class:`tapeforms.contrib.bootstrap.Bootstrap4TapeformMixin`
to render forms with a `Bootstrap 4`_ compatible HTML layout / CSS classes, or
:py:class:`tapeforms.contrib.bootstrap.Bootstrap5TapeformMixin` for `Bootstrap 5`_.

This alternative mixin makes sure that the rendered widgets, fields and labels
have the correct css classes assigned.
Expand All @@ -17,6 +18,9 @@ In addition, the mixin uses a different template for the fields because Bootstra
requires that the ordering of label and widget inside a field is swapped (widget
first, label second).

.. _Bootstrap 4: https://getbootstrap.com/docs/4.6/
.. _Bootstrap 5: https://getbootstrap.com/docs/5.0/


Foundation mixin
----------------
Expand Down
37 changes: 31 additions & 6 deletions examples/basic/forms.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from django import forms

from tapeforms.mixins import TapeformMixin
from tapeforms.contrib.bootstrap import BootstrapTapeformMixin
from tapeforms.contrib.bootstrap import (
Bootstrap4TapeformMixin,
Bootstrap5TapeformMixin,
)
from tapeforms.contrib.foundation import FoundationTapeformMixin


Expand Down Expand Up @@ -30,15 +33,37 @@ class SimpleWithOverridesForm(TapeformMixin, forms.Form):
special_text = forms.IntegerField(label='A number')


class SimpleBootstrapForm(BootstrapTapeformMixin, forms.Form):
first_name = forms.CharField(label='First name')
last_name = forms.CharField(label='Last name', help_text='Some hints')
confirm = forms.BooleanField(label='Please confirm')
choose_options = forms.MultipleChoiceField(label='Please choose', choices=(
class SimpleBootstrapBaseForm(forms.Form):
name = forms.CharField(label='Name', help_text='Some hints')
email = forms.EmailField(label='Email', required=False)
subject = forms.ChoiceField(label='Subject', choices=(
('option1', 'Option 1'),
('option2', 'Option 2'),
('option3', 'Option 3')
))
message = forms.CharField(label='Message', widget=forms.Textarea(attrs={
'rows': '3'
}))
attachment = forms.FileField(label='Attachment', required=False)
multiple_options = forms.MultipleChoiceField(label='Multiple', choices=(
('option1', 'Option 1'),
('option2', 'Option 2'),
('option3', 'Option 3')
))
choose_options = forms.ChoiceField(label='Please choose', choices=(
('foo', 'foo'),
('bar', 'bar'),
('baz', 'bar')
), widget=forms.RadioSelect)
confirm = forms.BooleanField(label='Please confirm')


class SimpleBootstrap4Form(Bootstrap4TapeformMixin, SimpleBootstrapBaseForm):
pass


class SimpleBootstrap5Form(Bootstrap5TapeformMixin, SimpleBootstrapBaseForm):
pass


class SimpleFoundationForm(FoundationTapeformMixin, forms.Form):
Expand Down
24 changes: 24 additions & 0 deletions examples/basic/templates/basic/bootstrap5_view.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{% load tapeforms %}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Simple Bootstrap v5</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
</head>

<body>
<div class="p-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">My form</h5>
<form action="." method="post" novalidate>
{% csrf_token %}
{% form form %}
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
</div>
</div>
</body>
</html>
12 changes: 9 additions & 3 deletions examples/basic/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@
from django.urls import include, path

from .views import (
SimpleBootstrapView, SimpleFoundationView, SimpleMultiWidgetView,
SimpleView, SimpleWithOverridesView)
SimpleBootstrap4View,
SimpleBootstrap5View,
SimpleFoundationView,
SimpleMultiWidgetView,
SimpleView,
SimpleWithOverridesView,
)


urlpatterns = [
path('simple/', SimpleView.as_view()),
path('overrides/', SimpleWithOverridesView.as_view()),
path('bootstrap/', SimpleBootstrapView.as_view()),
path('bootstrap4/', SimpleBootstrap4View.as_view()),
path('bootstrap5/', SimpleBootstrap5View.as_view()),
path('foundation/', SimpleFoundationView.as_view()),
path('multiwidget/', SimpleMultiWidgetView.as_view()),
]
20 changes: 15 additions & 5 deletions examples/basic/views.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from django.views.generic import FormView

from .forms import (
SimpleBootstrapForm, SimpleFoundationForm, SimpleMultiWidgetForm,
SimpleForm, SimpleWithOverridesForm)
SimpleBootstrap4Form,
SimpleBootstrap5Form,
SimpleFoundationForm,
SimpleMultiWidgetForm,
SimpleForm,
SimpleWithOverridesForm,
)


class SimpleView(FormView):
Expand All @@ -15,9 +20,14 @@ class SimpleWithOverridesView(FormView):
template_name = 'basic/simple_view.html'


class SimpleBootstrapView(FormView):
form_class = SimpleBootstrapForm
template_name = 'basic/bootstrap_view.html'
class SimpleBootstrap4View(FormView):
form_class = SimpleBootstrap4Form
template_name = 'basic/bootstrap4_view.html'


class SimpleBootstrap5View(FormView):
form_class = SimpleBootstrap5Form
template_name = 'basic/bootstrap5_view.html'


class SimpleFoundationView(FormView):
Expand Down
63 changes: 46 additions & 17 deletions tapeforms/contrib/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@
from ..mixins import TapeformMixin


class BootstrapTapeformMixin(TapeformMixin):
class Bootstrap4TapeformMixin(TapeformMixin):
"""
Tapeform Mixin to render Bootstrap4 compatible forms.
Tapeform Mixin to render Bootstrap v4 compatible forms.
(using the template tags provided by `tapeforms`).
"""

#: Use a special layout template for Bootstrap compatible forms.
layout_template = 'tapeforms/layouts/bootstrap.html'
#: Use a special field template for Bootstrap compatible forms.
field_template = 'tapeforms/fields/bootstrap.html'
#: Bootstrap requires that the field has a css class "form-group" applied.
#: All form field containers need a CSS class "form-group".
field_container_css_class = 'form-group'
#: All widgets need a css class "form-control" (except checkables and file inputs).
#: Almost all widgets need a CSS class "form-control".
widget_css_class = 'form-control'
#: Use a special class to invalid field's widget.
widget_invalid_css_class = 'is-invalid'
Expand All @@ -30,48 +30,77 @@ class BootstrapTapeformMixin(TapeformMixin):

def get_field_container_css_class(self, bound_field):
"""
Returns 'form-check' if widget is CheckboxInput in addition of the
Returns "form-check" if widget is CheckboxInput in addition of the
default value from the form property ("form-group") - which is returned
for all other fields.
"""
class_name = super().get_field_container_css_class(bound_field)

# If we render CheckboxInputs, Bootstrap requires an additional
# container class for checkboxes.
if isinstance(bound_field.field.widget, forms.CheckboxInput):
class_name += ' form-check'

return class_name

def get_field_label_css_class(self, bound_field):
"""
Returns 'form-check-label' if widget is CheckboxInput. For all other fields,
no css class is added.
Returns "form-check-label" if widget is CheckboxInput. For all other fields,
no CSS class is added.
"""
# If we render CheckboxInputs, Bootstrap requires a different
# field label css class for checkboxes.
if isinstance(bound_field.field.widget, forms.CheckboxInput):
return 'form-check-label'

return super().get_field_label_css_class(bound_field)

def get_widget_css_class(self, field_name, field):
"""
Returns 'form-check-input' if input widget is checkable, or
'form-control-file' if widget is FileInput. For all other fields
return the default value from the form property ("form-control").
Returns "form-check-input" if input widget is checkable, or
"form-control-file" if widget is FileInput. For all other fields,
returns the default value from the form property ("form-control").
"""
# If we render checkable input widget, Bootstrap requires a different
# widget css class for checkboxes.
if field.widget.__class__ in [
forms.RadioSelect,
forms.CheckboxSelectMultiple,
forms.CheckboxInput,
]:
return 'form-check-input'

# Idem for fileinput.
if isinstance(field.widget, forms.FileInput):
return 'form-control-file'

return super().get_widget_css_class(field_name, field)


class Bootstrap5TapeformMixin(Bootstrap4TapeformMixin):
"""
Tapeform Mixin to render Bootstrap v5 compatible forms.
(using the template tags provided by `tapeforms`).
"""

#: Apply the CSS class "mb-3" to add spacing between the form fields.
field_container_css_class = 'mb-3'
#: Almost all labels need a CSS class "form-label".
field_label_css_class = 'form-label'

def get_widget_css_class(self, field_name, field):
"""
Returns "form-check-input" if input widget is checkable, or
"form-select" if widget is Select or a subclass. For all other fields,
returns the default value from the form property ("form-control").
"""
if field.widget.__class__ in [
forms.RadioSelect,
forms.CheckboxSelectMultiple,
forms.CheckboxInput,
]:
return 'form-check-input'

if isinstance(field.widget, forms.Select):
return 'form-select'

return super(Bootstrap4TapeformMixin, self).get_widget_css_class(field_name, field)


#: This alias is for backward compatibility only. It could be deprecated and
#: removed at some time, you should use :py:class:`Bootstrap4TapeformMixin`
#: or :py:class:`Bootstrap5TapeformMixin` instead.
BootstrapTapeformMixin = Bootstrap4TapeformMixin
2 changes: 1 addition & 1 deletion tapeforms/templates/tapeforms/fields/bootstrap.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@

{% block help_text %}
{% if help_text %}
<div class="small form-text">{{ help_text }}</div>
<div class="form-text">{{ help_text }}</div>
{% endif %}
{% endblock %}
2 changes: 1 addition & 1 deletion tapeforms/templates/tapeforms/layouts/bootstrap.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@

{% block errors %}
{% for error in errors %}
<div class="alert alert-error">{{ error }}</div>
<div class="alert alert-error" role="alert">{{ error }}</div>
{% endfor %}
{% endblock %}
2 changes: 1 addition & 1 deletion tests/contrib/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
def pytest_generate_tests(metafunc):
# Set the field_name parametrization for the form class defined in
# FormFieldsSnapshotTestMixin derived class
if issubclass(metafunc.cls, FormFieldsSnapshotTestMixin):
if metafunc.cls and issubclass(metafunc.cls, FormFieldsSnapshotTestMixin):
if 'field_name' in metafunc.fixturenames:
try:
fields = metafunc.cls.form_class.declared_fields.keys()
Expand Down
41 changes: 32 additions & 9 deletions tests/contrib/test_bootstrap.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,48 @@
from django import forms

from tapeforms.contrib.bootstrap import BootstrapTapeformMixin
from tapeforms.contrib.bootstrap import (
Bootstrap4TapeformMixin,
Bootstrap5TapeformMixin,
BootstrapTapeformMixin,
)

from . import FormFieldsSnapshotTestMixin


class DummyForm(BootstrapTapeformMixin, forms.Form):
CHOICES = (('foo', 'Foo'), ('bar', 'Bar'))


class DummyBaseForm(forms.Form):
text = forms.CharField()
checkbox = forms.BooleanField()
clearable_file = forms.FileField(required=False)
radio_buttons = forms.MultipleChoiceField(
choices=(('foo', 'foo'), ('bar', 'bar'), ('baz', 'bar')), widget=forms.RadioSelect
)
radio_select = forms.MultipleChoiceField(choices=CHOICES, widget=forms.RadioSelect)


class Dummy4Form(Bootstrap4TapeformMixin, DummyBaseForm):
pass


class Dummy5Form(Bootstrap5TapeformMixin, DummyBaseForm):
select = forms.ChoiceField(choices=CHOICES)
select_multiple = forms.MultipleChoiceField(choices=CHOICES)

class TestBootstrapTapeformMixin(FormFieldsSnapshotTestMixin):
form_class = DummyForm
snapshot_dir = 'bootstrap'

def test_compatibility_mixin():
assert BootstrapTapeformMixin == Bootstrap4TapeformMixin


class TestBootstrap4TapeformMixin(FormFieldsSnapshotTestMixin):
form_class = Dummy4Form
snapshot_dir = 'bootstrap4'

def test_apply_widget_invalid_options(self):
form = DummyForm({})
form = self.form_class({})
assert 'text' in form.errors
widget = form.fields['text'].widget
assert sorted(widget.attrs['class'].split(' ')) == ['form-control', 'is-invalid']


class TestBootstrap5TapeformMixin(FormFieldsSnapshotTestMixin):
form_class = Dummy5Form
snapshot_dir = 'bootstrap5'
18 changes: 0 additions & 18 deletions tests/snapshots/bootstrap/field_radio_buttons.html

This file was deleted.

0 comments on commit 33edadc

Please sign in to comment.