Skip to content

Commit a818e95

Browse files
committed
extract subclass to deal with conditional field logic
We need to handle conditional validation in most of our forms, so I want to create something reusable for this.
1 parent dd514bf commit a818e95

File tree

2 files changed

+254
-0
lines changed

2 files changed

+254
-0
lines changed
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""
2+
Helpers to handle conditionally required fields
3+
"""
4+
5+
from dataclasses import dataclass
6+
from typing import Any
7+
8+
from django.forms import Form, ValidationError
9+
10+
11+
@dataclass
12+
class ConditionalRequirement:
13+
predicate_field: str
14+
predicate_field_value: Any
15+
conditionally_required_field: str
16+
17+
18+
class FieldPredicate:
19+
def __init__(self, validator, field_name, field_choices):
20+
self.validator = validator
21+
self.field_name = field_name
22+
self.field_choices = field_choices
23+
24+
def require_field_with_prefix(self, prefix):
25+
for value, _ in self.field_choices:
26+
self.validator.require_field_with_value(
27+
predicate_field=self.field_name,
28+
predicate_field_value=value,
29+
conditionally_required_field=f"{prefix}_{value.lower()}",
30+
)
31+
32+
33+
class FieldValuePredicate:
34+
def __init__(self, validator, field, field_value):
35+
self.validator = validator
36+
self.field = field
37+
self.field_value = field_value
38+
39+
def require_field(self, conditionally_required_field):
40+
self.validator.require_field_with_value(
41+
predicate_field=self.field,
42+
predicate_field_value=self.field_value,
43+
conditionally_required_field=conditionally_required_field,
44+
)
45+
46+
47+
class ConditionalFieldValidator:
48+
"""
49+
Helper class to perform the conditional validation for the FormWithConditionalFields
50+
"""
51+
52+
def __init__(self, form):
53+
self.conditional_requirements = []
54+
self.form = form
55+
56+
def require_field_with_value(
57+
self, conditionally_required_field, predicate_field, predicate_field_value
58+
):
59+
"""
60+
Mark a field as conditionally required if and only if another field (the predicate field)
61+
is set to a specific value.
62+
If the predicate field is set to the predicate value, this field will require a value.
63+
If the predicate field is set to a different value, this field's value will be ignored.
64+
"""
65+
if conditionally_required_field not in self.form.fields:
66+
raise ValueError(f"{conditionally_required_field} is not a valid field")
67+
if predicate_field not in self.form.fields:
68+
raise ValueError(f"{predicate_field} is not a valid field")
69+
70+
self.conditional_requirements.append(
71+
ConditionalRequirement(
72+
conditionally_required_field=conditionally_required_field,
73+
predicate_field=predicate_field,
74+
predicate_field_value=predicate_field_value,
75+
)
76+
)
77+
78+
self.form.fields[conditionally_required_field].required = False
79+
80+
def clean_conditional_fields(self):
81+
form = self.form
82+
for requirement in self.conditional_requirements:
83+
field = requirement.conditionally_required_field
84+
predicate_field_value = form.cleaned_data.get(requirement.predicate_field)
85+
86+
if predicate_field_value == requirement.predicate_field_value:
87+
cleaned_value = form.cleaned_data.get(field)
88+
if isinstance(cleaned_value, str):
89+
cleaned_value = cleaned_value.strip()
90+
91+
if not cleaned_value:
92+
form.add_error(
93+
field,
94+
ValidationError(
95+
message=form.fields[field].error_messages["required"],
96+
code="required",
97+
),
98+
)
99+
else:
100+
del form.cleaned_data[field]
101+
102+
103+
class FormWithConditionalFields(Form):
104+
"""
105+
This form class makes it possible to declare conditional relationships between two fields.
106+
E.g. if the user selects a particular value of one field (the predicate field) then
107+
another field (the conditional field) should become required.
108+
109+
Declare the fields normally, then in the `__init__`, after the superclass has been initialised,
110+
call `given_field_value().require_field()` or `given_field().require_field_with_prefix()`
111+
to declare the relationships.
112+
"""
113+
114+
def __init__(self, *args, **kwargs):
115+
self.conditional_field_validator = ConditionalFieldValidator(self)
116+
117+
super().__init__(*args, **kwargs)
118+
119+
def given_field_value(self, field, field_value):
120+
"""
121+
Mini-DSL to declare conditional field relationships
122+
123+
e.g. self.given_field_value('foo', 'choice1').require_field('other_details')
124+
"""
125+
return FieldValuePredicate(
126+
self.conditional_field_validator, field=field, field_value=field_value
127+
)
128+
129+
def given_field(self, predicate_field):
130+
"""
131+
Mini-DSL to declare conditional field relationships
132+
133+
e.g. self.given_field('foo').require_field_with_prefix('other')
134+
"""
135+
return FieldPredicate(
136+
self.conditional_field_validator,
137+
field_name=predicate_field,
138+
field_choices=self.fields[predicate_field].choices,
139+
)
140+
141+
def clean_conditional_fields(self):
142+
"""
143+
Apply the validation and blank out any conditional fields that do not have their predicate met.
144+
This can happen when the user selects one option, fills out the conditional field, and then changes
145+
to a different option.
146+
"""
147+
return self.conditional_field_validator.clean_conditional_fields()
148+
149+
def clean(self):
150+
cleaned_data = super().clean()
151+
self.clean_conditional_fields()
152+
153+
return cleaned_data
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import pytest
2+
from django.forms import CharField, ChoiceField
3+
4+
from manage_breast_screening.nhsuk_forms.forms import FormWithConditionalFields
5+
6+
7+
class TestFormWithConditionalFields:
8+
def test_does_nothing_if_no_fields_declared(self):
9+
class MyForm(FormWithConditionalFields):
10+
foo = CharField()
11+
12+
form = MyForm({"foo": "bar"})
13+
assert form.is_valid()
14+
assert form.cleaned_data["foo"] == "bar"
15+
16+
@pytest.fixture
17+
def FormWithSimpleConditional(self):
18+
class MyForm(FormWithConditionalFields):
19+
foo = ChoiceField(choices=(("a", "a"), ("b", "b")))
20+
bar = CharField()
21+
22+
def __init__(self, *args, **kwargs):
23+
super().__init__(*args, **kwargs)
24+
25+
self.given_field_value("foo", "a").require_field("bar")
26+
27+
return MyForm
28+
29+
@pytest.fixture
30+
def FormWithConditionalFieldsPerValue(self):
31+
class MyForm(FormWithConditionalFields):
32+
foo = ChoiceField(choices=(("a", "a"), ("b", "b")))
33+
other_a = CharField()
34+
other_b = CharField()
35+
36+
def __init__(self, *args, **kwargs):
37+
super().__init__(*args, **kwargs)
38+
39+
self.given_field("foo").require_field_with_prefix("other")
40+
41+
return MyForm
42+
43+
def test_simple_conditional_condition_not_met(self, FormWithSimpleConditional):
44+
form = FormWithSimpleConditional({"foo": "b"})
45+
assert form.is_valid()
46+
assert form.cleaned_data["foo"] == "b"
47+
48+
def test_simple_conditional_condition_met(self, FormWithSimpleConditional):
49+
form = FormWithSimpleConditional({"foo": "a", "bar": "abc"})
50+
assert form.is_valid()
51+
assert form.cleaned_data["foo"] == "a"
52+
assert form.cleaned_data["bar"] == "abc"
53+
54+
def test_simple_conditional_condition_missing_value(
55+
self, FormWithSimpleConditional
56+
):
57+
form = FormWithSimpleConditional({"foo": "a"})
58+
assert not form.is_valid()
59+
assert form.errors == {"bar": ["This field is required."]}
60+
61+
def test_simple_conditional_with_unused_value(self, FormWithSimpleConditional):
62+
form = FormWithSimpleConditional({"foo": "b", "bar": "abc"})
63+
assert form.is_valid()
64+
assert form.cleaned_data["foo"] == "b"
65+
assert not form.cleaned_data.get("bar")
66+
67+
def test_simnple_conditional_predicate_missing(self, FormWithSimpleConditional):
68+
form = FormWithSimpleConditional({})
69+
assert not form.is_valid()
70+
assert form.errors == {"foo": ["This field is required."]}
71+
72+
def test_per_value_conditional_missing_value(
73+
self, FormWithConditionalFieldsPerValue
74+
):
75+
form = FormWithConditionalFieldsPerValue({"foo": "b"})
76+
assert not form.is_valid()
77+
assert form.errors == {"other_b": ["This field is required."]}
78+
79+
def test_per_value_conditional_provided_value(
80+
self, FormWithConditionalFieldsPerValue
81+
):
82+
form = FormWithConditionalFieldsPerValue({"foo": "a", "other_a": "abc"})
83+
assert form.is_valid()
84+
assert form.cleaned_data["foo"] == "a"
85+
assert form.cleaned_data["other_a"] == "abc"
86+
87+
def test_per_value_conditional_with_unused_value(
88+
self, FormWithConditionalFieldsPerValue
89+
):
90+
form = FormWithConditionalFieldsPerValue(
91+
{"foo": "b", "other_a": "abc", "other_b": "def"}
92+
)
93+
assert form.is_valid()
94+
assert form.cleaned_data["foo"] == "b"
95+
assert form.cleaned_data["other_b"] == "def"
96+
assert not form.cleaned_data.get("other_a")
97+
98+
def test_per_value_predicate_missing(self, FormWithConditionalFieldsPerValue):
99+
form = FormWithConditionalFieldsPerValue({})
100+
assert not form.is_valid()
101+
assert form.errors == {"foo": ["This field is required."]}

0 commit comments

Comments
 (0)