Skip to content

Commit

Permalink
Add column support for fieldsets.
Browse files Browse the repository at this point in the history
  • Loading branch information
stephrdev committed Jan 16, 2025
1 parent afd079d commit 1a8c8bb
Show file tree
Hide file tree
Showing 13 changed files with 174 additions and 23 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
Changelog
=========

2.1.0 - 2025-01-16
------------------

* Add columns support for fieldsets


2.0.0 - 2025-01-15
------------------

Expand Down
16 changes: 10 additions & 6 deletions docs/fieldsets.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ Fieldsets

Learn how to organize your form fields in multiple fieldsets and have them rendered nicely.

The fieldset configuration also allowed rendering the fields in columns,
similar to Django's ModelAdmin.fields feature.


Manual fieldsets
----------------
Expand All @@ -17,13 +20,14 @@ control of whats happening.
field1 = forms.CharField()
field2 = forms.CharField()
field3 = forms.CharField()
field4 = forms.CharField(widget=forms.HiddenInput)
field4 = forms.CharField()
field5 = forms.CharField(widget=forms.HiddenInput)
def first_fieldset(self):
return TapeformFieldset(self, fields=('field1', 'field2'), primary=True)
return TapeformFieldset(self, fields=('field1', ('field2', 'field3')), primary=True)
def second_fieldset(self):
return TapeformFieldset(self, exclude=('field1', 'field1'))
return TapeformFieldset(self, exclude=('field1', 'field2', 'field3'))
.. note::

Expand Down Expand Up @@ -103,7 +107,7 @@ the ``get_fieldsets`` method and pass a config to your super call.

.. code-block:: python
class MyForm(TapeformMixin, forms.Form):
class MyForm(TapeformFieldsetsMixin, TapeformMixin, forms.Form):
field1 = forms.CharField()
field2 = forms.CharField()
field3 = forms.CharField()
Expand All @@ -125,7 +129,7 @@ we assume that we require a css class added to the fieldset element.

.. code-block:: python
class MyForm(TapeformMixin, forms.Form):
class MyForm(TapeformFieldsetsMixin, TapeformMixin, forms.Form):
field1 = forms.CharField()
field2 = forms.CharField()
field3 = forms.CharField()
Expand Down Expand Up @@ -154,7 +158,7 @@ we assume that we require a css class added to the fieldset element.


The extra key in the fieldset configuration is not checked in any way. Its just passed
around. You might use it to carry things in a ``dic`` like in the example or push
around. You might use it to carry things in a ``dict`` like in the example or push
a model instance to the template for further use.


Expand Down
22 changes: 19 additions & 3 deletions examples/fieldsets/forms.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django import forms
from tapeforms.contrib.bootstrap import Bootstrap5TapeformMixin
from tapeforms.fieldsets import TapeformFieldset, TapeformFieldsetsMixin
from tapeforms.mixins import TapeformMixin

Expand Down Expand Up @@ -29,9 +30,6 @@ def other_stuff(self):
class PropertyFieldsetsForm(TapeformFieldsetsMixin, LargeForm):
fieldsets = (
{
"extra": {
"title": "Basic",
},
"fields": ("first_name", "last_name"),
},
{
Expand All @@ -42,3 +40,21 @@ class PropertyFieldsetsForm(TapeformFieldsetsMixin, LargeForm):
"exclude": ("first_name", "last_name"),
},
)


class BootstrapFieldsetsForm(Bootstrap5TapeformMixin, TapeformFieldsetsMixin, LargeForm):
birthdate = forms.DateField()

fieldsets = (
{
"title": "Some fancy title",
"fields": (("first_name", "last_name"),),
},
{
"exclude": ("first_name", "last_name"),
},
)

def clean(self):
if not self.cleaned_data.get("first_name") or not self.cleaned_data.get("last_name"):
self.add_error(None, "Some name fields are missing.")
21 changes: 21 additions & 0 deletions examples/fieldsets/templates/fieldsets/bootstrap5_view.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{% 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/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
</head>

<body>
<div class="p-4">
<form action="." method="post" novalidate>
{% csrf_token %}
{% for fieldset in form.get_fieldsets %}
{% form fieldset %}
{% endfor %}
<button type="submit">Submit</button>
</form>
</div>
</body>
</html>
3 changes: 2 additions & 1 deletion examples/fieldsets/urls.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from django.urls import path

from .views import ManualFieldsetsView, PropertyFieldsetsView
from .views import BootstrapFieldsetsView, ManualFieldsetsView, PropertyFieldsetsView

urlpatterns = [
path("manual/", ManualFieldsetsView.as_view()),
path("property/", PropertyFieldsetsView.as_view()),
path("bootstrap/", BootstrapFieldsetsView.as_view()),
]
7 changes: 6 additions & 1 deletion examples/fieldsets/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.views.generic import FormView

from .forms import ManualFieldsetsForm, PropertyFieldsetsForm
from .forms import BootstrapFieldsetsForm, ManualFieldsetsForm, PropertyFieldsetsForm


class ManualFieldsetsView(FormView):
Expand All @@ -11,3 +11,8 @@ class ManualFieldsetsView(FormView):
class PropertyFieldsetsView(FormView):
form_class = PropertyFieldsetsForm
template_name = "fieldsets/property.html"


class BootstrapFieldsetsView(FormView):
form_class = BootstrapFieldsetsForm
template_name = "fieldsets/bootstrap5_view.html"
7 changes: 7 additions & 0 deletions tapeforms/contrib/bootstrap.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from django import forms

from ..fieldsets import TapeformFieldset
from ..mixins import TapeformMixin


class BootstrapTapeformFieldset(TapeformFieldset):
layout_template = "tapeforms/fieldsets/bootstrap.html"


class Bootstrap4TapeformMixin(TapeformMixin):
"""
Tapeform Mixin to render Bootstrap v4 compatible forms.
Expand All @@ -28,6 +33,8 @@ class Bootstrap4TapeformMixin(TapeformMixin):
forms.CheckboxSelectMultiple: "tapeforms/widgets/bootstrap_multipleinput.html",
}

fieldset_class = BootstrapTapeformFieldset

def get_field_container_css_class(self, bound_field):
"""
Returns "form-check" if widget is CheckboxInput in addition of the
Expand Down
1 change: 1 addition & 0 deletions tapeforms/defaults.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
LAYOUT_DEFAULT_TEMPLATE = "tapeforms/layouts/default.html"
FIELDSET_DEFAULT_TEMPLATE = "tapeforms/fieldsets/default.html"
FIELD_DEFAULT_TEMPLATE = "tapeforms/fields/default.html"
45 changes: 39 additions & 6 deletions tapeforms/fieldsets.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import copy
import itertools

from django.forms.utils import ErrorList

from . import defaults
from .mixins import TapeformLayoutMixin


Expand All @@ -12,8 +14,17 @@ class TapeformFieldset(TapeformLayoutMixin):
to render: ``form``.
"""

layout_template = defaults.FIELDSET_DEFAULT_TEMPLATE

def __init__(
self, form, fields=None, exclude=None, primary=False, template=None, extra=None
self,
form,
fields=None,
exclude=None,
primary=False,
title=None,
template=None,
extra=None,
):
"""
Initializes a fieldset instance to be used like a form in a template.
Expand All @@ -24,6 +35,7 @@ def __init__(
:param form: The form instance to take fields from.
:param fields: A list of visible field names to include in this fieldset.
Supports columns similar to Django's admin fields.
:param exclude: A list of visible fields to _not_ include in this fieldset.
:param primary: If the fieldset is `primary`, this fieldset is responsible
for rendering the hidden fields and non field errors.
Expand All @@ -39,16 +51,18 @@ def __init__(
self.render_fields = fields or ()
self.exclude_fields = exclude or ()
self.primary_fieldset = primary
self.fieldset_title = title
self.extra = extra or {}

if template:
self.layout_template = template

def __repr__(self):
return "<{cls} form={form}, primary={primary}, fields=({fields})/({exclude})>".format(
return "<{cls} form={form}, primary={primary}, title={title}, fields=({fields})/({exclude})>".format(
cls=self.__class__.__name__,
form=repr(self.form),
primary=self.primary_fieldset,
title=self.fieldset_title,
fields=";".join(self.render_fields),
exclude=";".join(self.exclude_fields),
)
Expand Down Expand Up @@ -86,14 +100,33 @@ def visible_fields(self):
"""

form_visible_fields = self.form.visible_fields()
form_visible_fields_map = {field.name: field for field in form_visible_fields}

if self.render_fields:
fields = self.render_fields
fields = [
(field,) if not isinstance(field, (tuple, list)) else field
for field in self.render_fields
]
else:
fields = [field.name for field in form_visible_fields]
fields = [(field.name,) for field in form_visible_fields]

filtered_fields = [
field
for field in itertools.chain.from_iterable(fields)
if field not in self.exclude_fields
]

visible_field_rows = []

for field_names in fields:
field_row = []
for field_name in field_names:
if field_name in filtered_fields:
field_row.append(form_visible_fields_map[field_name])
if field_row:
visible_field_rows.append(field_row)

filtered_fields = [field for field in fields if field not in self.exclude_fields]
return [field for field in form_visible_fields if field.name in filtered_fields]
return visible_field_rows


class TapeformFieldsetsMixin:
Expand Down
27 changes: 27 additions & 0 deletions tapeforms/templates/tapeforms/fieldsets/bootstrap.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{% extends 'tapeforms/layouts/bootstrap.html' %}
{% load tapeforms %}


{% block fields %}
<div class="card mb-3">
{% if form.fieldset_title %}
<div class="card-header">{{ form.fieldset_title }}</div>
{% endif %}

<div class="card-body">
{{ block.super }}
</div>
</div>
{% endblock %}

{% block visible_fields %}
{% for field_row in visible_fields %}
<div class="{{ form.extra.row_css_class|default:'row' }}">
{% for field in field_row %}
<div class="{{ form.extra.col_css_class|default:'col' }}">
{% formfield field %}
</div>
{% endfor %}
</div>
{% endfor %}
{% endblock %}
23 changes: 23 additions & 0 deletions tapeforms/templates/tapeforms/fieldsets/default.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{% extends 'tapeforms/layouts/default.html' %}
{% load tapeforms %}


{% block fields %}
{% if form.fieldset_title %}
<h3>{{ form.fieldset_title }}</h3>
{% endif %}

{{ block.super }}
{% endblock %}

{% block visible_fields %}
{% for field_row in visible_fields %}
<div class="field-row">
{% for field in field_row %}
<div class="field-col">
{% formfield field %}
</div>
{% endfor %}
</div>
{% endfor %}
{% 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" role="alert">{{ error }}</div>
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endfor %}
{% endblock %}
17 changes: 12 additions & 5 deletions tapeforms/tests/test_fieldsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def test_init(self):
fieldset = TapeformFieldset(form, fields=("my_field1",))
assert fieldset.form == form
assert fieldset.render_fields == ("my_field1",)
assert fieldset.layout_template is None
assert fieldset.layout_template == "tapeforms/fieldsets/default.html"

def test_init_with_template(self):
form = DummyForm()
Expand All @@ -58,7 +58,7 @@ def test_repr(self):
assert repr(fieldset) == (
"<TapeformFieldset form=<DummyForm bound=False, valid=Unknown, "
"fields=(my_field1;my_field2;my_field3;my_field4)>, primary=False, "
"fields=()/(my_field3)>"
"title=None, fields=()/(my_field3)>"
)

def test_hidden_fields_primary(self):
Expand Down Expand Up @@ -86,19 +86,26 @@ def test_non_field_errors_not_primary(self):
def test_visible_fields_with_fields(self):
form = DummyForm()
fieldset = TapeformFieldset(form, fields=("my_field1",))
assert [f.name for f in fieldset.visible_fields()] == ["my_field1"]
assert [f.name for f in fieldset.visible_fields()[0]] == ["my_field1"]

def test_visible_fields_with_fields_and_exclude(self):
form = DummyForm()
fieldset = TapeformFieldset(
form, fields=("my_field1", "my_field2"), exclude=("my_field2",)
)
assert [f.name for f in fieldset.visible_fields()] == ["my_field1"]
assert [f.name for f in fieldset.visible_fields()[0]] == ["my_field1"]

def test_visible_fields_with_exclude(self):
form = DummyForm()
fieldset = TapeformFieldset(form, exclude=("my_field1",))
assert [f.name for f in fieldset.visible_fields()] == ["my_field2", "my_field4"]
assert [f.name for f in fieldset.visible_fields()[0]] == ["my_field2"]
assert [f.name for f in fieldset.visible_fields()[1]] == ["my_field4"]

def test_visible_fields_with_columns(self):
form = DummyForm()
fieldset = TapeformFieldset(form, fields=("my_field1", ("my_field2", "my_field4")))
assert [f.name for f in fieldset.visible_fields()[0]] == ["my_field1"]
assert [f.name for f in fieldset.visible_fields()[1]] == ["my_field2", "my_field4"]


class TestTapeformFieldsetsMixin:
Expand Down

0 comments on commit 1a8c8bb

Please sign in to comment.