Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions docs/source/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,75 @@ There is a `unicorn_errors` template tag that shows all errors for the component
</div>
```

## Validating object fields with `form_classes`

When a component attribute is itself an object — such as a Django Model instance — you can
use `form_classes` instead of `form_class`. `form_classes` is a dictionary that maps each
object field name on the component to a form class that describes how to validate that object's
sub-fields.

This enables validation of dotted `unicorn:model` paths like `book.title` and surfaces errors
under the same dotted key (e.g. `book.title`) in `unicorn.errors`.

```python
# book_component.py
from django import forms
from django_unicorn.components import UnicornView
from .models import Book

class BookForm(forms.ModelForm):
class Meta:
model = Book
fields = ("title", "date_published")

class BookView(UnicornView):
form_classes = {"book": BookForm}

book: Book = None

def __init__(self, **kwargs):
super().__init__(**kwargs)
if self.book is None:
self.book = Book()

def save(self):
if self.is_valid():
self.book.save()
```

```html
<!-- book-component.html -->
<div>
<input unicorn:model="book.title" type="text" id="title" /><br />
<span class="error">{{ unicorn.errors.book.title.0.message }}</span>

<input unicorn:model="book.date_published" type="text" id="date-published" /><br />
<span class="error">{{ unicorn.errors.book.date_published.0.message }}</span>

<button unicorn:click="save">Save</button>
</div>
```

```{note}
`is_valid()` and `self.validate()` work exactly the same as with `form_class`.
You can also pass only the dotted names you care about:
``self.validate(model_names=["book.title"])`` to check a single sub-field.
```

```{note}
You can define entries in `form_classes` for multiple object fields at once:

```python
form_classes = {
"book": BookForm,
"author": AuthorForm,
}
```
```

## ValidationError


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`.

```python
Expand Down
94 changes: 92 additions & 2 deletions src/django_unicorn/components/unicorn_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from django.core.exceptions import NON_FIELD_ERRORS
from django.db.models import Model
from django.forms import BaseForm
from django.forms.models import model_to_dict # lazy import
from django.forms.widgets import CheckboxInput, Select
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.utils.decorators import classonlymethod
Expand Down Expand Up @@ -610,6 +611,55 @@ def _get_form(self, data):
except Exception as e:
logger.exception(e)

@timed
def _get_object_forms(self) -> dict:
"""
Builds a dict of ``{field_name: form}`` for every entry in ``form_classes``.

For each ``(field_name, form_class)`` pair the component attribute named
``field_name`` is read. Its fields are extracted as a flat dict and used
as the form's ``data`` argument. ``form.is_valid()`` is called so that
``form.errors`` is populated.

Returns an empty dict when ``form_classes`` is not set.
"""
if not hasattr(self, "form_classes"):
return {}

result = {}
for field_name, form_cls in cast(dict, self.form_classes).items():
obj = getattr(self, field_name, None)
if obj is None:
form_data = {}
elif isinstance(obj, Model):
# Use model-to-dict so we get plain Python values

form_data = model_to_dict(obj)
# model_to_dict skips non-editable fields; also include raw attribute values
# for any field declared on the form that is missing from model_to_dict output.
try:
form_instance = form_cls()
for form_field_name in form_instance.fields:
if form_field_name not in form_data:
form_data[form_field_name] = getattr(obj, form_field_name, None)
except Exception: # noqa: S110
pass
elif isinstance(obj, dict):
form_data = obj
elif hasattr(obj, "__dict__"):
form_data = {k: v for k, v in vars(obj).items() if not k.startswith("_")}
else:
form_data = {}

try:
form = cast(Callable, form_cls)(data=form_data)
form.is_valid()
result[field_name] = form
except Exception as e:
logger.exception(e)

return result

@timed
def get_context_data(self, **kwargs):
"""
Expand Down Expand Up @@ -643,10 +693,12 @@ def is_valid(self, model_names: list | None = None) -> bool:
@timed
def validate(self, model_names: list | None = None) -> dict:
"""
Validates the data using the `form_class` set on the component.
Validates the data using the ``form_class`` or ``form_classes`` set on the component.

Args:
model_names: Only include validation errors for specified fields. If none, validate everything.
model_names: Only include validation errors for specified fields. If none,
validate everything. For object fields, pass the dotted path
(e.g. ``["book.title"]``).
"""
# TODO: Handle form.non_field_errors()?

Expand All @@ -655,6 +707,7 @@ def validate(self, model_names: list | None = None) -> dict:

self._validate_called = True

# ── Original form_class path ────────────────────────────────────────
data = self._attributes()
form = self._get_form(data)

Expand Down Expand Up @@ -685,8 +738,42 @@ def validate(self, model_names: list | None = None) -> dict:
else:
self.errors.update(form_errors)

# ── form_classes path ────────────────────────────────────────────────
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think this should be split out to its own function?

self._validate_object_forms(model_names)

return self.errors

@timed
def _validate_object_forms(self, model_names: list | None = None) -> None:
"""
Validates object fields using ``form_classes`` and merges dotted-path
errors (e.g. ``"book.title"``) into ``self.errors``.

Called by :meth:`validate`; split out for readability.
"""
object_forms = self._get_object_forms()

for field_name, obj_form in object_forms.items():
# Re-map each sub-form error key to its dotted path, e.g.
# "title" → "book.title" when field_name == "book".
obj_form_errors = obj_form.errors.get_json_data(escape_html=True)
dotted_errors = {f"{field_name}.{sub_key}": sub_errors for sub_key, sub_errors in obj_form_errors.items()}

# Apply the same "persist only errors that are still invalid" logic.
if self.errors:
keys_to_remove = [
key for key in self.errors if key.startswith(f"{field_name}.") and key not in dotted_errors
]
for key in keys_to_remove:
self.errors.pop(key)

if model_names is not None:
for key, value in dotted_errors.items():
if key in model_names or any(key.startswith(f"{name}.") for name in model_names):
self.errors[key] = value
else:
self.errors.update(dotted_errors)

@timed
def _attribute_names(self) -> list[str]:
"""
Expand Down Expand Up @@ -895,6 +982,9 @@ def _is_public(self, name: str) -> bool:
"called",
"login_not_required",
"form_class",
# Form validation configuration (server-side only, not synced to frontend)
"form_class",
"form_classes",
)
excludes = []

Expand Down
68 changes: 68 additions & 0 deletions tests/components/test_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
from django import forms
from tests.views.fake_components import (
FakeAuthenticationComponent,
FakeFormClassesComponent,
FakeValidationComponent,
FakeValidationForm,
)

from django_unicorn.components import UnicornView
from django_unicorn.serializer import InvalidFieldNameError
from example.books.models import Book


class ExampleComponent(UnicornView):
Expand Down Expand Up @@ -526,3 +528,69 @@ def test_meta_form_class_not_in_frontend_context():

component = FakeValidationComponent(component_id="test_form_class_not_in_context", component_name="example")
assert "form_class" not in component._attributes()


# ──────────────────────────────────────────────────────────────────────────────
# form_classes tests
# ──────────────────────────────────────────────────────────────────────────────


def test_form_classes_validate_all_fields_with_empty_object():
"""
When a component has ``form_classes = {"book": BookForm}`` and the book has no
title or date_published, calling ``validate()`` should populate errors for
both ``book.title`` and ``book.date_published``.
"""

component = FakeFormClassesComponent(component_id="test_form_classes_validate_all", component_name="example")

errors = component.validate()

assert "book.title" in errors
assert errors["book.title"][0]["code"] == "required"
assert "book.date_published" in errors
assert errors["book.date_published"][0]["code"] == "required"


def test_form_classes_validate_model_names_filtered():
"""
When ``model_names`` is specified, only the requested dotted keys should
appear in the errors dict.
"""

component = FakeFormClassesComponent(component_id="test_form_classes_filtered", component_name="example")

errors = component.validate(model_names=["book.title"])

assert "book.title" in errors
assert "book.date_published" not in errors


def test_form_classes_is_valid_with_empty_object():
"""``is_valid()`` should return ``False`` when required object fields are missing."""

component = FakeFormClassesComponent(component_id="test_form_classes_is_valid_false", component_name="example")

assert component.is_valid() is False


def test_form_classes_validate_stale_errors_removed():
"""
Errors for an object field that has since become valid should be removed on
the next ``validate()`` call.
"""

component = FakeFormClassesComponent(component_id="test_form_classes_stale_errors", component_name="example")

# First validate with empty object — both fields should error.
component.validate()
assert "book.title" in component.errors

# Simulate the user filling in the object; reset the flag and fix the object.
component._validate_called = False
component.book = Book(title="My Book", date_published="2024-01-01")

errors = component.validate()

assert "book.title" not in errors
assert "book.date_published" not in errors
23 changes: 23 additions & 0 deletions tests/views/fake_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,26 @@ class BugComponent(UnicornView):

def set_flavor(self, value: str = ""):
self.flavor = value


class FakeBookForm(forms.ModelForm):
class Meta:
model = Book
fields = ("title", "date_published")


class FakeFormClassesComponent(UnicornView):
"""Component that uses ``form_classes`` to validate an object field."""

template_name = "templates/test_component.html"
form_classes = {"book": FakeBookForm} # noqa: RUF012

book: Book = None # type: ignore[assignment]

def __init__(self, **kwargs):
super().__init__(**kwargs)
if self.book is None:
self.book = Book()

def save(self):
self.validate()
Loading
Loading