Skip to content

Commit 28c089f

Browse files
Support validation for object fields using form_classes mapping
1 parent b6cf14d commit 28c089f

File tree

5 files changed

+373
-2
lines changed

5 files changed

+373
-2
lines changed

docs/source/validation.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,75 @@ There is a `unicorn_errors` template tag that shows all errors for the component
146146
</div>
147147
```
148148

149+
## Validating object fields with `form_classes`
150+
151+
When a component attribute is itself an object — such as a Django Model instance — you can
152+
use `form_classes` instead of `form_class`. `form_classes` is a dictionary that maps each
153+
object field name on the component to a form class that describes how to validate that object's
154+
sub-fields.
155+
156+
This enables validation of dotted `unicorn:model` paths like `book.title` and surfaces errors
157+
under the same dotted key (e.g. `book.title`) in `unicorn.errors`.
158+
159+
```python
160+
# book_component.py
161+
from django import forms
162+
from django_unicorn.components import UnicornView
163+
from .models import Book
164+
165+
class BookForm(forms.ModelForm):
166+
class Meta:
167+
model = Book
168+
fields = ("title", "date_published")
169+
170+
class BookView(UnicornView):
171+
form_classes = {"book": BookForm}
172+
173+
book: Book = None
174+
175+
def __init__(self, **kwargs):
176+
super().__init__(**kwargs)
177+
if self.book is None:
178+
self.book = Book()
179+
180+
def save(self):
181+
if self.is_valid():
182+
self.book.save()
183+
```
184+
185+
```html
186+
<!-- book-component.html -->
187+
<div>
188+
<input unicorn:model="book.title" type="text" id="title" /><br />
189+
<span class="error">{{ unicorn.errors.book.title.0.message }}</span>
190+
191+
<input unicorn:model="book.date_published" type="text" id="date-published" /><br />
192+
<span class="error">{{ unicorn.errors.book.date_published.0.message }}</span>
193+
194+
<button unicorn:click="save">Save</button>
195+
</div>
196+
```
197+
198+
```{note}
199+
`is_valid()` and `self.validate()` work exactly the same as with `form_class`.
200+
You can also pass only the dotted names you care about:
201+
``self.validate(model_names=["book.title"])`` to check a single sub-field.
202+
```
203+
204+
```{note}
205+
You can define entries in `form_classes` for multiple object fields at once:
206+
207+
```python
208+
form_classes = {
209+
"book": BookForm,
210+
"author": AuthorForm,
211+
}
212+
```
213+
```
214+
149215
## ValidationError
150216
217+
151218
If you do not want to create a form class or you want to specifically target a nested field you can raise a `ValidationError` inside of an action method. The `ValidationError` can be instantiated with a `dict` with the model name as the key and error message as the value. A `code` keyword argument must also be passed in. The typical error codes used are `required` or `invalid`.
152219
153220
```python

src/django_unicorn/components/unicorn_view.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,56 @@ def _get_form(self, data):
587587
except Exception as e:
588588
logger.exception(e)
589589

590+
@timed
591+
def _get_object_forms(self) -> dict:
592+
"""
593+
Builds a dict of ``{field_name: form}`` for every entry in ``form_classes``.
594+
595+
For each ``(field_name, form_class)`` pair the component attribute named
596+
``field_name`` is read. Its fields are extracted as a flat dict and used
597+
as the form's ``data`` argument. ``form.is_valid()`` is called so that
598+
``form.errors`` is populated.
599+
600+
Returns an empty dict when ``form_classes`` is not set.
601+
"""
602+
if not hasattr(self, "form_classes"):
603+
return {}
604+
605+
result = {}
606+
for field_name, form_cls in self.form_classes.items():
607+
obj = getattr(self, field_name, None)
608+
if obj is None:
609+
form_data = {}
610+
elif isinstance(obj, Model):
611+
# Use model-to-dict so we get plain Python values
612+
from django.forms.models import model_to_dict # lazy import
613+
614+
form_data = model_to_dict(obj)
615+
# model_to_dict skips non-editable fields; also include raw attribute values
616+
# for any field declared on the form that is missing from model_to_dict output.
617+
try:
618+
form_instance = form_cls()
619+
for form_field_name in form_instance.fields:
620+
if form_field_name not in form_data:
621+
form_data[form_field_name] = getattr(obj, form_field_name, None)
622+
except Exception:
623+
pass
624+
elif isinstance(obj, dict):
625+
form_data = obj
626+
elif hasattr(obj, "__dict__"):
627+
form_data = {k: v for k, v in vars(obj).items() if not k.startswith("_")}
628+
else:
629+
form_data = {}
630+
631+
try:
632+
form = cast(Callable, form_cls)(data=form_data)
633+
form.is_valid()
634+
result[field_name] = form
635+
except Exception as e:
636+
logger.exception(e)
637+
638+
return result
639+
590640
@timed
591641
def get_context_data(self, **kwargs):
592642
"""
@@ -620,10 +670,12 @@ def is_valid(self, model_names: list | None = None) -> bool:
620670
@timed
621671
def validate(self, model_names: list | None = None) -> dict:
622672
"""
623-
Validates the data using the `form_class` set on the component.
673+
Validates the data using the ``form_class`` or ``form_classes`` set on the component.
624674
625675
Args:
626-
model_names: Only include validation errors for specified fields. If none, validate everything.
676+
model_names: Only include validation errors for specified fields. If none,
677+
validate everything. For object fields, pass the dotted path
678+
(e.g. ``["book.title"]``).
627679
"""
628680
# TODO: Handle form.non_field_errors()?
629681

@@ -632,6 +684,7 @@ def validate(self, model_names: list | None = None) -> dict:
632684

633685
self._validate_called = True
634686

687+
# ── Original form_class path ────────────────────────────────────────
635688
data = self._attributes()
636689
form = self._get_form(data)
637690

@@ -662,6 +715,35 @@ def validate(self, model_names: list | None = None) -> dict:
662715
else:
663716
self.errors.update(form_errors)
664717

718+
# ── form_classes path ────────────────────────────────────────────────
719+
object_forms = self._get_object_forms()
720+
721+
for field_name, obj_form in object_forms.items():
722+
# Re-map each sub-form error key to its dotted path, e.g.
723+
# "title" → "book.title" when field_name == "book".
724+
obj_form_errors = obj_form.errors.get_json_data(escape_html=True)
725+
dotted_errors = {
726+
f"{field_name}.{sub_key}": sub_errors
727+
for sub_key, sub_errors in obj_form_errors.items()
728+
}
729+
730+
# Apply the same "persist only errors that are still invalid" logic.
731+
if self.errors:
732+
keys_to_remove = [
733+
key
734+
for key in self.errors
735+
if key.startswith(f"{field_name}.") and key not in dotted_errors
736+
]
737+
for key in keys_to_remove:
738+
self.errors.pop(key)
739+
740+
if model_names is not None:
741+
for key, value in dotted_errors.items():
742+
if key in model_names or any(key.startswith(f"{name}.") for name in model_names):
743+
self.errors[key] = value
744+
else:
745+
self.errors.update(dotted_errors)
746+
665747
return self.errors
666748

667749
@timed
@@ -870,6 +952,8 @@ def _is_public(self, name: str) -> bool:
870952
"resolved",
871953
"calling",
872954
"called",
955+
# Form validation configuration (server-side only, not synced to frontend)
956+
"form_classes",
873957
)
874958
excludes = []
875959

tests/components/test_component.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,3 +401,84 @@ def __init__(self, *args, **kwargs):
401401

402402
context = component.get_context_data()
403403
assert context["form"] is form_instance
404+
405+
406+
# ──────────────────────────────────────────────────────────────────────────────
407+
# form_classes tests
408+
# ──────────────────────────────────────────────────────────────────────────────
409+
410+
411+
def test_form_classes_validate_all_fields_with_empty_object():
412+
"""
413+
When a component has ``form_classes = {"book": BookForm}`` and the book has no
414+
title or date_published, calling ``validate()`` should populate errors for
415+
both ``book.title`` and ``book.date_published``.
416+
"""
417+
from tests.views.fake_components import FakeFormClassesComponent
418+
419+
component = FakeFormClassesComponent(
420+
component_id="test_form_classes_validate_all", component_name="example"
421+
)
422+
423+
errors = component.validate()
424+
425+
assert "book.title" in errors
426+
assert errors["book.title"][0]["code"] == "required"
427+
assert "book.date_published" in errors
428+
assert errors["book.date_published"][0]["code"] == "required"
429+
430+
431+
def test_form_classes_validate_model_names_filtered():
432+
"""
433+
When ``model_names`` is specified, only the requested dotted keys should
434+
appear in the errors dict.
435+
"""
436+
from tests.views.fake_components import FakeFormClassesComponent
437+
438+
component = FakeFormClassesComponent(
439+
component_id="test_form_classes_filtered", component_name="example"
440+
)
441+
442+
errors = component.validate(model_names=["book.title"])
443+
444+
assert "book.title" in errors
445+
assert "book.date_published" not in errors
446+
447+
448+
def test_form_classes_is_valid_with_empty_object():
449+
"""``is_valid()`` should return ``False`` when required object fields are missing."""
450+
from tests.views.fake_components import FakeFormClassesComponent
451+
452+
component = FakeFormClassesComponent(
453+
component_id="test_form_classes_is_valid_false", component_name="example"
454+
)
455+
456+
assert component.is_valid() is False
457+
458+
459+
def test_form_classes_validate_stale_errors_removed():
460+
"""
461+
Errors for an object field that has since become valid should be removed on
462+
the next ``validate()`` call.
463+
"""
464+
from example.books.models import Book
465+
466+
from tests.views.fake_components import FakeFormClassesComponent
467+
468+
component = FakeFormClassesComponent(
469+
component_id="test_form_classes_stale_errors", component_name="example"
470+
)
471+
472+
# First validate with empty object — both fields should error.
473+
component.validate()
474+
assert "book.title" in component.errors
475+
476+
# Simulate the user filling in the object; reset the flag and fix the object.
477+
component._validate_called = False
478+
component.book = Book(title="My Book", date_published="2024-01-01")
479+
480+
errors = component.validate()
481+
482+
assert "book.title" not in errors
483+
assert "book.date_published" not in errors
484+

tests/views/fake_components.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,27 @@ class BugComponent(UnicornView):
206206

207207
def set_flavor(self, value: str = ""):
208208
self.flavor = value
209+
210+
211+
class FakeBookForm(forms.ModelForm):
212+
class Meta:
213+
model = Book
214+
fields = ("title", "date_published")
215+
216+
217+
class FakeFormClassesComponent(UnicornView):
218+
"""Component that uses ``form_classes`` to validate an object field."""
219+
220+
template_name = "templates/test_component.html"
221+
form_classes = {"book": FakeBookForm} # noqa: RUF012
222+
223+
book: Book = None # type: ignore[assignment]
224+
225+
def __init__(self, **kwargs):
226+
super().__init__(**kwargs)
227+
if self.book is None:
228+
self.book = Book()
229+
230+
def save(self):
231+
self.validate()
232+

0 commit comments

Comments
 (0)