diff --git a/inertia/__init__.py b/inertia/__init__.py index ded1ae7..ff67151 100644 --- a/inertia/__init__.py +++ b/inertia/__init__.py @@ -1,3 +1,4 @@ from .http import inertia, render from .utils import lazy from .share import share +from .validation import inertia_validate, InertiaValidationError \ No newline at end of file diff --git a/inertia/middleware.py b/inertia/middleware.py index aa01f42..8224792 100644 --- a/inertia/middleware.py +++ b/inertia/middleware.py @@ -2,12 +2,22 @@ from django.contrib import messages from django.http import HttpResponse from django.middleware.csrf import get_token +from .validation import InertiaValidationError, VALIDATION_ERRORS_SESSION_KEY +from .share import share class InertiaMiddleware: def __init__(self, get_response): self.get_response = get_response - + def __call__(self, request): + validation_errors = request.session.get(VALIDATION_ERRORS_SESSION_KEY, None) + + if self.is_inertia_get_request(request) and validation_errors is not None: + request.session.pop(VALIDATION_ERRORS_SESSION_KEY) + request.session.modified = True + # Must be shared before rendering the response + share(request, errors=validation_errors) + response = self.get_response(request) # Inertia requests don't ever render templates, so they skip the typical Django @@ -25,12 +35,22 @@ def __call__(self, request): return response + def process_exception(self, request, exception): + if isinstance(exception, InertiaValidationError): + errors = {field: errors[0] for field, errors in exception.errors.items()} + request.session[VALIDATION_ERRORS_SESSION_KEY] = errors + request.session.modified = True + return exception.redirect + def is_non_post_redirect(self, request, response): return self.is_redirect_request(response) and request.method in ['PUT', 'PATCH', 'DELETE'] def is_inertia_request(self, request): return 'X-Inertia' in request.headers + def is_inertia_get_request(self, request): + return request.method == "GET" and self.is_inertia_request(request) + def is_redirect_request(self, response): return response.status_code in [301, 302] diff --git a/inertia/tests/test_middleware.py b/inertia/tests/test_middleware.py index 81c65df..39ae58b 100644 --- a/inertia/tests/test_middleware.py +++ b/inertia/tests/test_middleware.py @@ -1,5 +1,7 @@ from django.test import TestCase, Client, override_settings from inertia.test import InertiaTestCase +from inertia.validation import VALIDATION_ERRORS_SESSION_KEY +from django.conf import settings class MiddlewareTestCase(InertiaTestCase): def test_anything(self): @@ -29,4 +31,24 @@ def test_a_request_not_from_inertia_is_ignored(self): HTTP_X_INERTIA_VERSION='some-nonsense', ) - self.assertEqual(response.status_code, 200) \ No newline at end of file + self.assertEqual(response.status_code, 200) + + def test_stores_validation_errors_in_session(self): + self.inertia.post('/form/', data={'invalid': 'data'}) + self.assertDictEqual(self.inertia.session[VALIDATION_ERRORS_SESSION_KEY], { + 'str_field': 'This field is required.', + 'num_field': 'This field is required.' + }) + + def test_pops_validation_errors_from_session(self): + self.inertia.post('/form/', data={'invalid': 'data'}) + self.inertia.get('/form/') + self.assertFalse(self.inertia.session.has_key(VALIDATION_ERRORS_SESSION_KEY)) + + def test_maintains_validation_errors_in_session_until_necessary(self): + self.inertia.post('/form/', data={'invalid': 'data'}) + # Some other non-inertia request before Inertia actually redirects + self.client.cookies[settings.SESSION_COOKIE_NAME] = self.inertia.session.session_key + self.client.get('/empty/') + + self.assertTrue(self.inertia.session.has_key(VALIDATION_ERRORS_SESSION_KEY)) diff --git a/inertia/tests/test_rendering.py b/inertia/tests/test_rendering.py index 1893e62..f0b83d5 100644 --- a/inertia/tests/test_rendering.py +++ b/inertia/tests/test_rendering.py @@ -109,3 +109,22 @@ def test_that_csrf_is_included_even_on_initial_page_load(self): response = self.client.get('/props/') self.assertIsNotNone(response.cookies.get('csrftoken')) + +class FormValidationTestCase(InertiaTestCase): + def test_inertia_receives_errors_prop(self): + submit_invalid_form_response = self.inertia.post( + path='/form/', + data={'invalid': 'data'}, + follow=True, + ) + + self.assertJSONResponse( + submit_invalid_form_response, + inertia_page('form', props={ + 'test': 'props', + 'errors': { + 'str_field': 'This field is required.', + 'num_field': 'This field is required.', + } + }) + ) diff --git a/inertia/tests/testapp/forms.py b/inertia/tests/testapp/forms.py new file mode 100644 index 0000000..ceb5ad5 --- /dev/null +++ b/inertia/tests/testapp/forms.py @@ -0,0 +1,5 @@ +from django import forms + +class TestForm(forms.Form): + str_field = forms.CharField(max_length=100, required=True) + num_field = forms.IntegerField(min_value=20, required=True) diff --git a/inertia/tests/testapp/urls.py b/inertia/tests/testapp/urls.py index c550a71..2755df9 100644 --- a/inertia/tests/testapp/urls.py +++ b/inertia/tests/testapp/urls.py @@ -11,4 +11,5 @@ path('complex-props/', views.complex_props_test), path('share/', views.share_test), path('inertia-redirect/', views.inertia_redirect_test), + path('form/', views.form_test), ] \ No newline at end of file diff --git a/inertia/tests/testapp/views.py b/inertia/tests/testapp/views.py index e48e74c..3c2707e 100644 --- a/inertia/tests/testapp/views.py +++ b/inertia/tests/testapp/views.py @@ -1,14 +1,15 @@ from django.http.response import HttpResponse from django.shortcuts import redirect from django.utils.decorators import decorator_from_middleware -from inertia import inertia, render, lazy, share +from inertia import inertia, render, lazy, share, InertiaValidationError +from .forms import TestForm class ShareMiddleware: def __init__(self, get_response): self.get_response = get_response def process_request(self, request): - share(request, + share(request, position=lambda: 'goalie', number=29, ) @@ -61,4 +62,17 @@ def complex_props_test(request): def share_test(request): return { 'name': 'Brandon', - } \ No newline at end of file + } + +def form_test(request): + # TODO: request.POST only works with the test HTTP client, Inertia sends + # JSON from the browser by default, not multipart/form-data + form = TestForm(request.POST) + + if request.method == "GET": + return render(request, 'TestComponent', {'test': 'props'}) + + if not form.is_valid(): + raise InertiaValidationError(form.errors, redirect(form_test)) + + return redirect(empty_test) diff --git a/inertia/validation.py b/inertia/validation.py new file mode 100644 index 0000000..5fa048a --- /dev/null +++ b/inertia/validation.py @@ -0,0 +1,23 @@ +from typing import Union + +from django.forms import Form +from django.forms.utils import ErrorDict +from django.http import HttpResponsePermanentRedirect, HttpResponseRedirect + +VALIDATION_ERRORS_SESSION_KEY = "_inertia_validation_errors" + +InertiaRedirect = Union[HttpResponseRedirect, HttpResponsePermanentRedirect] + + +class InertiaValidationError(Exception): + def __init__(self, errors: ErrorDict, redirect: InertiaRedirect): + super().__init__() + self.redirect = redirect + self.errors = errors + + +def inertia_validate(form: Form, redirect: InertiaRedirect): + if not form.is_valid(): + raise InertiaValidationError(form.errors, redirect) + + return form.cleaned_data