Skip to content

Implement form validation & redirect back with errors #32

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
1 change: 1 addition & 0 deletions inertia/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .http import inertia, render
from .utils import lazy
from .share import share
from .validation import inertia_validate, InertiaValidationError
22 changes: 21 additions & 1 deletion inertia/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]

Expand Down
24 changes: 23 additions & 1 deletion inertia/tests/test_middleware.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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)
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))
19 changes: 19 additions & 0 deletions inertia/tests/test_rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
}
})
)
5 changes: 5 additions & 0 deletions inertia/tests/testapp/forms.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions inertia/tests/testapp/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]
20 changes: 17 additions & 3 deletions inertia/tests/testapp/views.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand Down Expand Up @@ -61,4 +62,17 @@ def complex_props_test(request):
def share_test(request):
return {
'name': 'Brandon',
}
}

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)
23 changes: 23 additions & 0 deletions inertia/validation.py
Original file line number Diff line number Diff line change
@@ -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