Skip to content

Commit 3c64784

Browse files
Integrate more into Form rendering API
Override BoundField to allow fields to be rendered using '{{ field.as_field_group }}' instead of relying on template tags. This eliminates the need for '{% dsfr_form_field %}'. Also implement Toggle form field
1 parent e95a6b2 commit 3c64784

File tree

12 files changed

+857
-751
lines changed

12 files changed

+857
-751
lines changed

dsfr/fields.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django.core.exceptions import ValidationError
2-
from django.forms.fields import MultiValueField, IntegerField
2+
from django.forms.fields import MultiValueField, IntegerField, BooleanField
33

4-
from .widgets import NumberCursor
4+
from .widgets import NumberCursor, Toggle
55

66
__all__ = ["IntegerRangeField"]
77

@@ -49,3 +49,16 @@ def compress(self, data_list: list[int]) -> range:
4949
"Le second nombre doit être supérieur au premier pour déterminer un intervalle"
5050
)
5151
return range(data_list[0], data_list[1] + 1)
52+
53+
54+
class ToggleField(BooleanField):
55+
widget = Toggle
56+
template_name = "dsfr/toggle.html"
57+
58+
def __init__(self, **kwargs):
59+
kwargs["label_suffix"] = ""
60+
super().__init__(**kwargs)
61+
if self.disabled:
62+
self.widget.attrs.setdefault("disabled", "true")
63+
64+

dsfr/forms.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from pathlib import Path
22

3+
import django
34
from django import forms
4-
from django.forms import Form
5+
from django.forms import Form, BoundField as DjangoBoundField
56
from django.forms.renderers import DjangoTemplates, get_default_renderer
67
from django.utils.functional import cached_property
78

9+
810
from dsfr.utils import dsfr_input_class_attr
911

1012

@@ -24,17 +26,44 @@ def engine(self):
2426
)
2527

2628

29+
class BoundField(DjangoBoundField):
30+
@property
31+
def template_name(self):
32+
template_name = self.field.template_name or getattr(
33+
self.field.__class__, "template_name", None
34+
)
35+
if template_name:
36+
return template_name
37+
38+
match self.widget_type:
39+
case "checkboxinput":
40+
return "dsfr/form_field_snippets/checkbox_snippet.html"
41+
case "checkboxselectmultiple" | "inlinecheckboxselectmultiple":
42+
return "dsfr/form_field_snippets/checkboxselectmultiple_snippet.html"
43+
case "radioselect" | "inlineradioselect":
44+
return "dsfr/form_field_snippets/radioselect_snippet.html"
45+
case "richradioselect":
46+
return "dsfr/form_field_snippets/richradioselect_snippet.html"
47+
case "numbercursor":
48+
return "dsfr/form_field_snippets/numbercursor_snippet.html"
49+
case "segmentedcontrol":
50+
return "dsfr/form_field_snippets/segmented_control_snippet.html"
51+
case _:
52+
return "dsfr/form_field_snippets/input_snippet.html"
53+
54+
def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
55+
if hasattr(self.field.widget, "dsfr_label_attrs"):
56+
attrs = {**self.field.widget.dsfr_label_attrs, **(attrs or {})}
57+
return super().label_tag(contents, attrs, label_suffix, tag)
58+
59+
2760
class DsfrBaseForm(Form):
2861
"""
2962
A base form that adds the necessary classes on relevant fields
3063
"""
3164

3265
template_name = "dsfr/form_snippet.html" # type: ignore
33-
34-
def __init__(self, *args, **kwargs):
35-
super().__init__(*args, **kwargs)
36-
for visible in self.visible_fields():
37-
dsfr_input_class_attr(visible)
66+
bound_field_class = BoundField
3867

3968
@property
4069
def default_renderer(self):
@@ -46,6 +75,32 @@ def default_renderer(self):
4675
else get_default_renderer()
4776
)
4877

78+
def __init__(self, *args, **kwargs):
79+
super().__init__(*args, **kwargs)
80+
for visible in self.visible_fields():
81+
dsfr_input_class_attr(visible)
82+
83+
if django.VERSION < (5, 2):
84+
85+
def __getitem__(self, name):
86+
try:
87+
field = self.fields[name]
88+
except KeyError:
89+
raise KeyError(
90+
"Key '%s' not found in '%s'. Choices are: %s."
91+
% (
92+
name,
93+
self.__class__.__name__,
94+
", ".join(sorted(self.fields)),
95+
)
96+
)
97+
if name not in self._bound_fields_cache:
98+
bound_field_class = getattr(
99+
self, "bound_field_class", self.bound_field_class
100+
)
101+
self._bound_fields_cache[name] = bound_field_class(self, field, name)
102+
return self._bound_fields_cache[name]
103+
49104
def set_autofocus_on_first_error(self):
50105
"""
51106
Sets the autofocus on the first field with an error message.

dsfr/templates/dsfr/form_base.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
{# Everything that needs to be in the for loop #}
3737
{% block inside_form %}
3838
{% endblock inside_form %}
39-
{% include "dsfr/form_field_snippets/field_snippet.html" %}
39+
{{ field }}
4040
{% endfor %}
4141
{# If you need to add formsets after the form #}
4242
{% block extra_formset_after %}

dsfr/templates/dsfr/form_field_snippets/field_snippet.html

Lines changed: 0 additions & 18 deletions
This file was deleted.

dsfr/templates/dsfr/form_snippet.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,9 @@
55
</section>
66
{% endif %}
77
{% for field in form %}
8-
{% dsfr_form_field field %}
8+
{% if field.is_hidden %}
9+
{{ field.as_hidden }}
10+
{% else %}
11+
{{ field.as_field_group }}
12+
{% endif %}
913
{% endfor %}

dsfr/templates/dsfr/formset_base.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
{# everything that needs to be in the loop for each field for each form in formset #}
2222
{% block inside_formset_field %}
2323
{% endblock inside_formset_field %}
24-
{% include "dsfr/form_field_snippets/field_snippet.html" %}
24+
{{ field }}
2525
{% endfor %}
2626
<div class="fr-mt-2w">
2727
<button id="remove-0"

dsfr/templates/dsfr/toggle.html

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
1-
{% load i18n %}
2-
{% translate "Checked" as checked_label %}
3-
{% translate "Unchecked" as unchecked_label %}
4-
<div class="fr-toggle{% if self.extra_classes %} {{ self.extra_classes }}{% endif %}">
5-
<input type="checkbox"
6-
{% if self.is_disabled %}disabled{% endif %}
7-
class="fr-toggle__input"
8-
{% if self.help_text %}aria-describedby="{{ self.id }}-hint-text"{% endif %}
9-
id="{{ self.id }}">
10-
<label class="fr-toggle__label"
11-
for="{{ self.id }}"
12-
data-fr-checked-label="{{ checked_label }}"
13-
data-fr-unchecked-label="{{ unchecked_label }}">
14-
{{ self.label }}
15-
</label>
16-
{% if self.help_text %}
17-
<p class="fr-hint-text" id="{{ self.id }}-hint-text">
18-
{{ self.help_text }}
1+
<div class="{{ field.field.widget.dsfr_wrapper_class|default:"" }}{% if field.errors %} fr-toggle--error{% endif %}">
2+
{{ field }}
3+
{% if field.label %}
4+
{{ field.label_tag }}
5+
{% endif %}
6+
{% if field.help_text %}
7+
<p class="fr-hint-text" id="{{ field.field.widget.attrs.id }}-hint-text">
8+
{{ field.help_text }}
199
</p>
2010
{% endif %}
11+
{% if field.errors %}
12+
<div class="fr-messages-group"
13+
id="{{ field.field.widget.attrs.id }}-messages"
14+
aria-live="polite">
15+
{% for error in field.errors %}
16+
<p class="fr-message fr-message--error"
17+
id="{{ field.field.widget.attrs.id }}-message-error">
18+
{{ error }}
19+
</p>
20+
{% endfor %}
21+
</div>
22+
{% endif %}
2123
</div>

dsfr/templatetags/dsfr_tags.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
INTEGRITY_JS_MODULE,
1616
INTEGRITY_JS_NOMODULE,
1717
)
18+
from dsfr.forms import DsfrBaseForm
1819
from dsfr.utils import (
1920
find_active_menu_items,
2021
generate_random_id,
@@ -1343,7 +1344,7 @@ def dsfr_tile(*args, **kwargs) -> dict:
13431344
return {"self": tag_data}
13441345

13451346

1346-
@register.inclusion_tag("dsfr/toggle.html")
1347+
@register.simple_tag
13471348
def dsfr_toggle(*args, **kwargs) -> dict:
13481349
"""
13491350
Returns a toggle item. Takes a dict as parameter, with the following structure:
@@ -1383,13 +1384,31 @@ def dsfr_toggle(*args, **kwargs) -> dict:
13831384
]
13841385
tag_data = parse_tag_args(args, kwargs, allowed_keys)
13851386

1387+
warnings.warn(
1388+
"{% dsfr_toggle %} is deprecated; please use the ToggleField form field instead",
1389+
DeprecationWarning,
1390+
stacklevel=3,
1391+
)
1392+
13861393
if "id" not in tag_data:
13871394
tag_data["id"] = generate_random_id("toggle")
13881395

13891396
if "is_disabled" not in tag_data:
13901397
tag_data["is_disabled"] = False
13911398

1392-
return {"self": tag_data}
1399+
from ..fields import ToggleField
1400+
1401+
field = ToggleField(
1402+
required=False, label=tag_data["label"], help_text=tag_data.get("help_text", "")
1403+
)
1404+
if tag_data.get("is_disabled", False):
1405+
field.widget.attrs["disabled"] = tag_data["is_disabled"]
1406+
if tag_data.get("extra_classes", None):
1407+
field.widget.dsfr_wrapper_class += " " + tag_data["extra_classes"]
1408+
if tag_data.get("id", None):
1409+
field.widget.attrs["id"] = tag_data["id"]
1410+
form = type("ToggleForm", (DsfrBaseForm,), {"toggle": field})
1411+
return Template("{{ form.toggle.as_field_group }}").render(Context({"form": form}))
13931412

13941413

13951414
@register.inclusion_tag("dsfr/tooltip.html")
@@ -1587,8 +1606,8 @@ def _render_alert_tag(message):
15871606
)
15881607

15891608

1590-
@register.inclusion_tag("dsfr/form_field_snippets/field_snippet.html")
1591-
def dsfr_form_field(field) -> dict:
1609+
@register.simple_tag(takes_context=True)
1610+
def dsfr_form_field(context: Context, field) -> dict:
15921611
"""
15931612
Returns the HTML for a form field snippet
15941613
@@ -1607,7 +1626,17 @@ def dsfr_form_field(field) -> dict:
16071626
if field == "":
16081627
raise AttributeError("Invalid form field name in dsfr_form_field.")
16091628

1610-
return {"field": field}
1629+
warnings.warn(
1630+
(
1631+
"{% dsfr_form_field field %} is deprecated; please use {{ field.as_field_group }}"
1632+
"of {{ field.as_hidden }} in your template instead"
1633+
),
1634+
DeprecationWarning,
1635+
stacklevel=3,
1636+
)
1637+
1638+
with context.push("field", field) as ctx:
1639+
return Template("{{ field.as_field_group }}").render(ctx)
16111640

16121641

16131642
register.filter(name="dsfr_input_class_attr", filter_func=dsfr_input_class_attr)

dsfr/test/test_templatetags.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,8 +1093,9 @@ def test_toggle_rendered(self):
10931093
<div class="fr-toggle fr-toggle--label-left fr-toggle--border-bottom">
10941094
<input type="checkbox"
10951095
class="fr-toggle__input"
1096-
aria-describedby="toggle-full-hint-text"
1097-
id="toggle-full">
1096+
aria-describedby="id_toggle_helptext"
1097+
id="toggle-full"
1098+
name="toggle">
10981099
<label class="fr-toggle__label"
10991100
for="toggle-full"
11001101
data-fr-checked-label="Activé"

dsfr/widgets.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
import warnings
23
from typing import Type
34

@@ -6,19 +7,21 @@
67
ChoiceWidget,
78
CheckboxSelectMultiple,
89
NumberInput,
10+
CheckboxInput
911
)
1012
from django.http import QueryDict
1113
from django.utils.datastructures import MultiValueDict
14+
from django.utils.translation import gettext_lazy
1215

1316
from dsfr.enums import ExtendedChoices
1417

15-
1618
__all__ = [
1719
"RichRadioSelect",
1820
"InlineRadioSelect",
1921
"InlineCheckboxSelectMultiple",
2022
"NumberCursor",
2123
"SegmentedControl",
24+
"Toggle"
2225
]
2326

2427

@@ -219,3 +222,17 @@ def __init__(
219222
super().__init__(*args, **kwargs)
220223
self.extra_classes = extra_classes
221224
self.is_inline = is_inline
225+
226+
227+
class Toggle(CheckboxInput):
228+
dsfr_wrapper_class = "fr-toggle"
229+
dsfr_input_class = "fr-toggle__input"
230+
dsfr_label_attrs = {"data-fr-checked-label": gettext_lazy("Checked"), "data-fr-unchecked-label": gettext_lazy("Unchecked"), "class": "fr-toggle__label"}
231+
232+
def __init__(self, attrs=None, check_test=None, dsfr_input_class=None, dsfr_label_attrs=None, dsfr_wrapper_class=None):
233+
super().__init__(attrs, check_test)
234+
self.dsfr_input_class = dsfr_input_class or self.dsfr_input_class
235+
self.dsfr_label_attrs = dsfr_label_attrs or self.dsfr_label_attrs
236+
self.dsfr_wrapper_class = dsfr_wrapper_class or self.dsfr_wrapper_class
237+
css_class = re.sub(rf"\s*{re.escape(self.dsfr_input_class)}\s*", "", self.attrs.get("class", ""))
238+
self.attrs["class"] = f"{self.dsfr_input_class} {css_class}".strip()

0 commit comments

Comments
 (0)