diff --git a/docs/source/form-submission.md b/docs/source/form-submission.md new file mode 100644 index 0000000..885d688 --- /dev/null +++ b/docs/source/form-submission.md @@ -0,0 +1,15 @@ +# Form Submission + +By default, Turbo will intercept all clicks on links and form submission, as for form submission, if the form validation fail on the server side, Turbo expects the server return `422 Unprocessable Entity`. + +In Django, however, failed form submission would still return HTTP `200`, which would cause some issues when working with Turbo Drive. + +[https://turbo.hotwired.dev/handbook/drive#redirecting-after-a-form-submission](https://turbo.hotwired.dev/handbook/drive#redirecting-after-a-form-submission) + +How to solve this issue? + +`turbo_helper.middleware.TurboMiddleware` can detect POST request from Turbo and change the response status code to `422` if the form validation failed. + +It should work out of the box for `Turbo 8+` on the frontend. + +So developers do not need to manually set the status code to `422` in the view. diff --git a/docs/source/index.rst b/docs/source/index.rst index c171cb1..bb88707 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -17,6 +17,7 @@ Topics :maxdepth: 2 install.md + form-submission.md dom_id.md turbo_frame.md turbo_stream.md @@ -24,5 +25,4 @@ Topics extend-turbo-stream.md multi-format.md signal-decorator.md - redirect.md test.md diff --git a/docs/source/install.md b/docs/source/install.md index ddf52bc..352d449 100644 --- a/docs/source/install.md +++ b/docs/source/install.md @@ -37,7 +37,7 @@ MIDDLEWARE = [ ] ``` -With the `TurboMiddleware` we have `request.turbo` object which we can access in Django view or template. +With the `TurboMiddleware` we have `request.turbo` object which we can access in Django view or template. It will also help us to change the response status code to `422` if the POST request come from Turbo and the form validation failed. If the request originates from a turbo-frame, we can get the value from the `request.turbo.frame` diff --git a/docs/source/redirect.md b/docs/source/redirect.md deleted file mode 100644 index 7c0ba06..0000000 --- a/docs/source/redirect.md +++ /dev/null @@ -1,11 +0,0 @@ -# Redirects - -As per the [documentation](https://turbo.hotwire.dev/handbook/drive#redirecting-after-a-form-submission), Turbo expects a 303 redirect after a form submission. - -If your project has `PUT`, `PATCH`, `DELETE` requests, then you should take a look at this [Clarification on redirect status code (303)](https://github.com/hotwired/turbo/issues/84#issuecomment-862656931) - -```python -from turbo_response import redirect_303 - -return redirect_303("/") -``` diff --git a/src/turbo_helper/middleware.py b/src/turbo_helper/middleware.py index b87018d..b8fb877 100644 --- a/src/turbo_helper/middleware.py +++ b/src/turbo_helper/middleware.py @@ -1,3 +1,4 @@ +import http import threading from typing import Callable @@ -53,9 +54,13 @@ def __bool__(self): class TurboMiddleware: - """Adds `turbo` attribute to request: + """ + Task 1: Adds `turbo` attribute to request: 1. `request.turbo` : True if request contains turbo header 2. `request.turbo.frame`: DOM ID of requested Turbo-Frame (or None) + + Task 2: Auto change status code for Turbo Drive + https://turbo.hotwired.dev/handbook/drive#redirecting-after-a-form-submission """ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): @@ -64,5 +69,21 @@ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): def __call__(self, request: HttpRequest) -> HttpResponse: with SetCurrentRequest(request): request.turbo = SimpleLazyObject(lambda: TurboData(request)) + response = self.get_response(request) + + if ( + request.method == "POST" + and request.headers.get("X-Turbo-Request-Id") + and response.get("Content-Type") != "text/vnd.turbo-stream.html" + ): + if response.status_code == http.HTTPStatus.OK: + response.status_code = http.HTTPStatus.UNPROCESSABLE_ENTITY + + if response.status_code in ( + http.HTTPStatus.MOVED_PERMANENTLY, + http.HTTPStatus.FOUND, + ): + response.status_code = http.HTTPStatus.SEE_OTHER + return response diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 9018e32..92bd56a 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,7 +1,14 @@ +import http + import pytest -from django.http import HttpResponse +from django.http import ( + HttpResponse, + HttpResponsePermanentRedirect, + HttpResponseRedirect, +) from turbo_helper.middleware import TurboMiddleware +from turbo_helper.response import TurboStreamResponse @pytest.fixture @@ -11,23 +18,94 @@ def get_response(): class TestTurboMiddleware: def test_accept_header_not_found(self, rf, get_response): - req = rf.get("/", HTTP_ACCEPT="text/html") + headers = { + "ACCEPT": "text/html", + } + headers = {f"HTTP_{key.upper()}": value for key, value in headers.items()} + req = rf.get("/", **headers) TurboMiddleware(get_response)(req) assert not req.turbo assert req.turbo.frame is None def test_accept_header_found(self, rf, get_response): - req = rf.get("/", HTTP_ACCEPT="text/vnd.turbo-stream.html") + headers = { + "ACCEPT": "text/vnd.turbo-stream.html", + } + headers = {f"HTTP_{key.upper()}": value for key, value in headers.items()} + req = rf.get("/", **headers) TurboMiddleware(get_response)(req) assert req.turbo assert req.turbo.frame is None def test_turbo_frame(self, rf, get_response): - req = rf.get( - "/", - HTTP_ACCEPT="text/vnd.turbo-stream.html", - HTTP_TURBO_FRAME="my-playlist", - ) + headers = { + "ACCEPT": "text/vnd.turbo-stream.html", + "TURBO_FRAME": "my-playlist", + } + headers = { + f"HTTP_{key.upper()}": value for key, value in headers.items() + } # Add "HTTP_" prefix + req = rf.get("/", **headers) TurboMiddleware(get_response)(req) assert req.turbo assert req.turbo.frame == "my-playlist" + + +class TestTurboMiddlewareAutoChangeStatusCode: + def test_post_failed_form_submission(self, rf): + headers = { + "ACCEPT": "text/vnd.turbo-stream.html", + "X-Turbo-Request-Id": "d4165765-488b-41a0-82b6-39126c40e3e0", + } + headers = { + f"HTTP_{key.upper()}": value for key, value in headers.items() + } # Add "HTTP_" prefix + req = rf.post("/", **headers) + + def form_submission(request): + # in Django, failed form submission will return 200 + return HttpResponse() + + resp = TurboMiddleware(form_submission)(req) + + assert resp.status_code == http.HTTPStatus.UNPROCESSABLE_ENTITY + + @pytest.mark.parametrize( + "response_class", [HttpResponseRedirect, HttpResponsePermanentRedirect] + ) + def test_post_succeed_form_submission(self, rf, response_class): + headers = { + "ACCEPT": "text/vnd.turbo-stream.html", + "X-Turbo-Request-Id": "d4165765-488b-41a0-82b6-39126c40e3e0", + } + headers = { + f"HTTP_{key.upper()}": value for key, value in headers.items() + } # Add "HTTP_" prefix + req = rf.post("/", **headers) + + def form_submission(request): + # in Django, failed form submission will return 301, 302 + return response_class("/success/") + + resp = TurboMiddleware(form_submission)(req) + + assert resp.status_code == http.HTTPStatus.SEE_OTHER + + def test_post_turbo_stream(self, rf, get_response): + """ + Do not change if response is TurboStreamResponse + """ + headers = { + "ACCEPT": "text/vnd.turbo-stream.html", + "X-Turbo-Request-Id": "d4165765-488b-41a0-82b6-39126c40e3e0", + } + headers = { + f"HTTP_{key.upper()}": value for key, value in headers.items() + } # Add "HTTP_" prefix + req = rf.post("/", **headers) + + def form_submission(request): + return TurboStreamResponse() + + resp = TurboMiddleware(form_submission)(req) + assert resp.status_code == http.HTTPStatus.OK