From e0c7786e38c866ec7e50cfabf1e5842baa1e5916 Mon Sep 17 00:00:00 2001 From: Michael Yin Date: Tue, 7 Nov 2023 10:50:33 +0800 Subject: [PATCH] Feature/15 templatetag (#16) --- docs/source/channels.md | 58 ++ docs/source/form.md | 200 ++++++ docs/source/index.rst | 25 +- docs/source/install.md | 51 ++ docs/source/main.md | 596 ------------------ docs/source/original.md | 247 ++++++++ docs/source/redirect.md | 13 + docs/source/template-tags.md | 89 +++ docs/source/test.md | 28 + setup.cfg | 2 +- src/turbo_response/__init__.py | 2 + src/turbo_response/apps.py | 6 + src/turbo_response/templatetags/__init__.py | 0 .../templatetags/turbo_helper.py | 175 +++++ tests/test_tags.py | 103 +++ 15 files changed, 988 insertions(+), 607 deletions(-) create mode 100644 docs/source/channels.md create mode 100644 docs/source/form.md create mode 100644 docs/source/install.md delete mode 100644 docs/source/main.md create mode 100644 docs/source/original.md create mode 100644 docs/source/redirect.md create mode 100644 docs/source/template-tags.md create mode 100644 docs/source/test.md create mode 100644 src/turbo_response/apps.py create mode 100644 src/turbo_response/templatetags/__init__.py create mode 100644 src/turbo_response/templatetags/turbo_helper.py create mode 100644 tests/test_tags.py diff --git a/docs/source/channels.md b/docs/source/channels.md new file mode 100644 index 0000000..ad9b424 --- /dev/null +++ b/docs/source/channels.md @@ -0,0 +1,58 @@ +# Django-Channels + +This library can also be used with [django-channels](https://channels.readthedocs.io/en/stable/). As with multiple streams, you can use the **TurboStream** class to broadcast turbo-stream content from your consumers. + +```python +from turbo_response import render_turbo_stream, render_turbo_stream_template +from channels.generic.websocket import AsyncJsonWebsocketConsumer + +class ChatConsumer(AsyncJsonWebsocketConsumer): + + async def chat_message(self, event): + + # DB methods omitted for brevity + message = await self.get_message(event["message"]["id"]) + num_unread_messages = await self.get_num_unread_messages() + + if message: + await self.send( + TurboStream("unread_message_counter") + .replace.render(str(num_unread_messages)) + ) + + await self.send( + TurboStream("messages").append.template( + "chat/_message.html", + {"message": message, "user": self.scope['user']}, + ).render() + ) +``` + +See the django-channels documentation for more details on setting up ASGI and channels. Note that you will need to set up your WebSockets in the client, for example in a Stimulus controller: + +```javascript +import { Controller } from 'stimulus'; +import { connectStreamSource, disconnectStreamSource } from '@hotwired/turbo'; + +export default class extends Controller { + static values = { + socketUrl: String, + }; + + connect() { + this.source = new WebSocket(this.socketUrlValue); + connectStreamSource(this.source); + } + + disconnect() { + if (this.source) { + disconnectStreamSource(this.source); + this.source.close(); + this.source = null; + } + } +} +``` + +**Note** if you want to add reactivity directly to your models, so that model changes broadcast turbo-streams automatically, we recommend the [turbo-django](https://github.com/hotwire-django/turbo-django) package. + diff --git a/docs/source/form.md b/docs/source/form.md new file mode 100644 index 0000000..df60c83 --- /dev/null +++ b/docs/source/form.md @@ -0,0 +1,200 @@ +# Form Validation + +The most common pattern for server-side validation in a Django view consists of: + +1. Render the initial form +2. Validate on POST +3. If any validation errors, re-render the form with errors and user input +4. If no validation errors, save to the database (and/or any other actions) and redirect + +In order to make this work with Turbo you can do one of two things (**Note**: requires minimum **@hotwired/turbo 7.0.0-beta.3**): + +1. When the form is invalid, return with a 4** status response. +2. Add *data-turbo="false"* to your `
` tag. + +If neither of these options are set, Turbo will throw an error if your view returns any response that isn't a redirect. + +Note that if you set *data-turbo="false"* on your form like so: + +```html + +``` + +Turbo will force a full-page refresh, just as the same attribute does to link behavior. This might be acceptable however when working with views and forms e.g. in 3rd party packages where you don't want to change the default workflow. + +If you want to continue using forms with Turbo just change the response status to a 4**, e.g. 422: + +```python +import http + +from django.shortcuts import redirect +from django.template.response import TemplateResponse + +from myapp import MyForm + +def my_view(request): + if request.method == "POST": + form = MyForm(request.POST) + if form.is_valid(): + # save data etc... + return redirect("/") + status = http.HTTPStatus.UNPROCESSABLE_ENTITY + else: + form = MyForm() + status = http.HTTPStatus.OK + return TemplateResponse(request, "my_form.html", {"form": my_form}, status=status) +``` + +As this is such a common pattern, we provide for convenience the **turbo_response.render_form_response** shortcut function which automatically sets the correct status depending on the form state (and adds "form" to the template context): + +```python +from django.shortcuts import redirect + +from turbo_response import render_form_response + +from myapp import MyForm + +def my_view(request): + if request.method == "POST": + form = MyForm(request.POST) + if form.is_valid(): + # save data etc... + return redirect("/") + else: + form = MyForm() + return render_form_response(request, form, "my_form.html") +``` + +If you are using CBVs, this package has a mixin class, **turbo_response.mixins.TurboFormMixin** that sets the correct status automatically to 422 for an invalid form: + +```python +from django.views.generic import FormView + +from turbo_response import redirect_303 +from turbo_response.mixins import TurboFormMixin + +from myapp import MyForm + +class MyView(TurboFormMixin, FormView): + template_name = "my_form.html" + + def form_valid(self, form): + return redirect_303("/") +``` + +In addition you can just subclass these views for common cases: + +- **turbo_response.views.TurboFormView** +- **turbo_response.views.TurboCreateView** +- **turbo_response.views.TurboUpdateView** + +In some cases you may wish to return a turbo-stream response containing just the form when the form is invalid instead of a full page visit. In this case just return a stream rendering the form partial in the usual manner. For example: + +```python +from django.shortcuts import redirect_303 +from django.template.response import TemplateResponse +from django.views.generic import FormView + +from turbo_response import TurboStream + +from myapp import MyForm + +def my_view(request): + if request.method == "POST": + form = MyForm(request.POST) + if form.is_valid(): + # save data etc... + return redirect_303("/") + return TurboStream("form-target").replace.template("_my_form.html").render(request=request) + else: + form = MyForm() + return TemplateResponse(request, "my_form.html", {"form": my_form}) +``` + +Or CBV: + +```python +class MyView(TurboFormMixin, FormView): + template_name = "my_form.html" + + def form_valid(self, form): + return redirect_303("/") + + def form_invalid(self, form): + return TurboStream("form-target").replace.template("_my_form.html").render(request=request) +``` + +And your templates would look like this: + +*my_form.html* + +```html +{% extends "base.html" %} + +{% block content %} +

my form goes here..

+{% include "_my_form.html" %} +{% endblock content %} +``` + +*_my_form.html* + +```html + + {% csrf_token %} + {{ form.as_p }} +
+``` + +As this is a useful pattern in many situations, for example when handling forms inside modals, this package provides a mixin class **turbo_response.mixins.TurboStreamFormMixin**: + +```python +from django.views.generic import FormView +from turbo_response.mixins import TurboStreamFormMixin + +class MyView(TurboStreamFormMixin, FormView): + turbo_stream_target = "form-target" + template_name = "my_form.html" + # action = Action.REPLACE +``` + +This mixin will automatically add the target name to the template context as *turbo_stream_target*. The partial template will be automatically resolved as the template name prefixed with an underscore: in this example, *_my_form.html*. You can also set it explicitly with the *turbo_stream_template_name* class attribute. The default action is "replace". + +As with the form mixin above, the package includes a number of view classes using this mixin: + +- **turbo_response.views.TurboStreamFormView** +- **turbo_response.views.TurboStreamCreateView** +- **turbo_response.views.TurboStreamUpdateView** + +So the above example could be rewritten as: + +```python +from turbo_response.views import TurboStreamFormView + +class MyView(TurboStreamFormView): + turbo_stream_target = "form-target" + template_name = "my_form.html" +``` + +The model-based classes automatically set the target DOM ID based on the model. The pattern for **TurboStreamCreateView** is *form-* and for **TurboStreamUpdateView** *form--*. You can override this by setting the *target* attribute explicitly or overriding the *get_turbo_stream_target* method. + +A final point re: forms: Turbo processes forms using the FormData API and only includes inputs with a value. This means all buttons, inputs etc. must have a value. For example suppose you have a button like this: + +```html + +``` + +If your view code checks for this value: + +```python +if "send_action" in request.POST: + ... +``` + +it will consistently fail. You should have something like: + +```html + +``` + +to ensure the FormData object includes the button value. diff --git a/docs/source/index.rst b/docs/source/index.rst index c8f6588..39800e0 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,15 +1,20 @@ -.. django-turbo-response documentation master file, created by - sphinx-quickstart on Tue Dec 29 02:19:26 2020. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - django-turbo-response -========================================== +===================== + +This package provides helpers for server-side rendering of *Turbo Frame* and *Turbo Stream*, which aim to work with the `Hotwire `_ project. + +.. _topics: -.. include:: ./main.md - :parser: myst +Topics +------ .. toctree:: - :hidden: + :maxdepth: 2 - self + install.md + template-tags.md + original.md + form.md + redirect.md + channels.md + test.md diff --git a/docs/source/install.md b/docs/source/install.md new file mode 100644 index 0000000..3599198 --- /dev/null +++ b/docs/source/install.md @@ -0,0 +1,51 @@ +# Installation + +## Requirements + +This library requires Python 3.8+ and Django 3.2+. + +## Getting Started + +```shell +pip install django-turbo-response +``` + +Next update **INSTALLED_APPS**: + +```python +INSTALLED_APPS = [ + "turbo_response", + ... +] +``` + +**Note**: This install does not include any client libraries (e.g. Turbo or Stimulus). You may wish to add these yourself using your preferred Javascript build tool, or use a CDN. Please refer to the Hotwire documentation on installing these libraries. + +## Middleware + +You can optionally install `turbo_response.middleware.TurboMiddleware`. This adds the attribute `turbo` to your `request` if the Turbo client adds `Accept: text/vnd.turbo-stream.html;` to the header: + +```python +MIDDLEWARE = [ + ... + "turbo_response.middleware.TurboMiddleware", + "django.middleware.common.CommonMiddleware", + ... +] +``` + +This is useful if you want to check if a stream is requested, so you can optionally return a stream or a normal response: + +```python +if request.turbo: + # return Turbo Stream +else: + # return normal response +``` + +If the request originates from a turbo-frame it will also set the `frame` property: + +```python +if request.turbo.frame == "my-playlist": + pass +``` diff --git a/docs/source/main.md b/docs/source/main.md deleted file mode 100644 index 7c2dbc6..0000000 --- a/docs/source/main.md +++ /dev/null @@ -1,596 +0,0 @@ -This package provides helpers for server-side rendering of `Turbo Frame` and `Turbo Stream`, which aim to work with the [Hotwire](https://hotwire.dev/) project. - -## Requirements - -This library requires Python 3.8+ and Django 3.0+. - -## Getting Started - -```shell -pip install django-turbo-response -``` - -Next update **INSTALLED_APPS**: - -```python -INSTALLED_APPS = [ - "turbo_response", - ... -] -``` - -**Note**: This install does not include any client libraries (e.g. Turbo or Stimulus). You may wish to add these yourself using your preferred Javascript build tool, or use a CDN. Please refer to the Hotwire documentation on installing these libraries. - -## Middleware - -You can optionally install `turbo_response.middleware.TurboMiddleware`. This adds the attribute `turbo` to your request if the Turbo client adds `Accept: text/vnd.turbo-stream.html;` to the header: - -```python -MIDDLEWARE = [ - ... - "turbo_response.middleware.TurboMiddleware", - "django.middleware.common.CommonMiddleware", - ... -] -``` - -This is useful if you want to check if a stream is requested, so you can optionally return a stream or a normal response: - -```python -if request.turbo: - return TurboStream("item").replace.response("OK") -else: - return redirect("index") -``` - -If the request originates from a turbo-frame it will also set the `frame` property: - -```python -if request.turbo.frame == "my-playlist": - return TurboFrame(request.turbo.frame).response("OK") -``` - -## TurboFrame and TurboStream - -A couple of classes, **TurboFrame** and **TurboStream**, provide the basic API for rendering streams and frames. - -To render plain strings: - -```python -from turbo_response import TurboFrame, TurboStream, Action - -# returns -TurboStream("msg").replace.render("OK") - -# returns -TurboStream(".msg", is_multiple=True).replace.render("OK") - -# set action dynamically -TurboStream("msg").action(Action.REPLACE).render("OK") - -# returns -TurboStream("msg").remove.render() - -# returns OK -TurboFrame("msg").render("OK") -``` - -You can also render templates: - -```python -TurboStream("msg").replace.template("msg.html", {"msg": "hello"}).render() - -TurboStream(".msg", is_multiple=True).replace.template("msg.html", {"msg": "hello"}).render() - -TurboFrame("msg").template("msg.html", {"msg": "hello"}).render() -``` - -You can also return an `HTTPResponse` subclass. The content type `text/html; turbo-stream;` will be added to turbo stream responses. - -```python -def my_stream(request): - return TurboStream("msg").replace.response("OK") - -def my_frame(request): - return TurboFrame("msg").response("OK") - -def my_tmpl_stream(request): - return TurboStream("msg").replace.template("msg.html", {"msg": "OK"}).response(request) - -def my_tmpl_frame(request): - return TurboFrame("msg").template("msg.html", {"msg": "OK"}).response(request) -``` - -**Note** if you are using the plain TurboStream or TurboFrame `render()` and `response()` non-template methods, any HTML will be automatically escaped. To prevent this pass `is_safe` (assuming you know the HTML is safe, of course): - -```python -TurboStream("msg").replace.render("OK", is_safe=True) - -TurboFrame("msg").response("OK", is_safe=True) -``` - -You don't need to do this with the template methods as HTML output is assumed: - -```python -TurboFrame("msg").template("msg.html", {"msg": "OK"}).response(request) -``` - -See the API docs for more details. - -## Form Validation - -The most common pattern for server-side validation in a Django view consists of: - -1. Render the initial form -2. Validate on POST -3. If any validation errors, re-render the form with errors and user input -4. If no validation errors, save to the database (and/or any other actions) and redirect - -In order to make this work with Turbo you can do one of two things (**Note**: requires minimum **@hotwired/turbo 7.0.0-beta.3**): - -1. When the form is invalid, return with a 4** status response. -2. Add *data-turbo="false"* to your `
` tag. - -If neither of these options are set, Turbo will throw an error if your view returns any response that isn't a redirect. - -Note that if you set *data-turbo="false"* on your form like so: - -```html - -``` - -Turbo will force a full-page refresh, just as the same attribute does to link behavior. This might be acceptable however when working with views and forms e.g. in 3rd party packages where you don't want to change the default workflow. - -If you want to continue using forms with Turbo just change the response status to a 4**, e.g. 422: - -```python -import http - -from django.shortcuts import redirect -from django.template.response import TemplateResponse - -from myapp import MyForm - -def my_view(request): - if request.method == "POST": - form = MyForm(request.POST) - if form.is_valid(): - # save data etc... - return redirect("/") - status = http.HTTPStatus.UNPROCESSABLE_ENTITY - else: - form = MyForm() - status = http.HTTPStatus.OK - return TemplateResponse(request, "my_form.html", {"form": my_form}, status=status) -``` - -As this is such a common pattern, we provide for convenience the **turbo_response.render_form_response** shortcut function which automatically sets the correct status depending on the form state (and adds "form" to the template context): - -```python -from django.shortcuts import redirect - -from turbo_response import render_form_response - -from myapp import MyForm - -def my_view(request): - if request.method == "POST": - form = MyForm(request.POST) - if form.is_valid(): - # save data etc... - return redirect("/") - else: - form = MyForm() - return render_form_response(request, form, "my_form.html") -``` - -If you are using CBVs, this package has a mixin class, **turbo_response.mixins.TurboFormMixin** that sets the correct status automatically to 422 for an invalid form: - -```python -from django.views.generic import FormView - -from turbo_response import redirect_303 -from turbo_response.mixins import TurboFormMixin - -from myapp import MyForm - -class MyView(TurboFormMixin, FormView): - template_name = "my_form.html" - - def form_valid(self, form): - return redirect_303("/") -``` - -In addition you can just subclass these views for common cases: - -- **turbo_response.views.TurboFormView** -- **turbo_response.views.TurboCreateView** -- **turbo_response.views.TurboUpdateView** - -In some cases you may wish to return a turbo-stream response containing just the form when the form is invalid instead of a full page visit. In this case just return a stream rendering the form partial in the usual manner. For example: - -```python -from django.shortcuts import redirect_303 -from django.template.response import TemplateResponse -from django.views.generic import FormView - -from turbo_response import TurboStream - -from myapp import MyForm - -def my_view(request): - if request.method == "POST": - form = MyForm(request.POST) - if form.is_valid(): - # save data etc... - return redirect_303("/") - return TurboStream("form-target").replace.template("_my_form.html").render(request=request) - else: - form = MyForm() - return TemplateResponse(request, "my_form.html", {"form": my_form}) -``` - -Or CBV: - -```python -class MyView(TurboFormMixin, FormView): - template_name = "my_form.html" - - def form_valid(self, form): - return redirect_303("/") - - def form_invalid(self, form): - return TurboStream("form-target").replace.template("_my_form.html").render(request=request) -``` - -And your templates would look like this: - -*my_form.html* - -```html -{% extends "base.html" %} - -{% block content %} -

my form goes here..

-{% include "_my_form.html" %} -{% endblock content %} -``` - -*_my_form.html* - -```html - - {% csrf_token %} - {{ form.as_p }} -
-``` - -As this is a useful pattern in many situations, for example when handling forms inside modals, this package provides a mixin class **turbo_response.mixins.TurboStreamFormMixin**: - -```python -from django.views.generic import FormView -from turbo_response.mixins import TurboStreamFormMixin - -class MyView(TurboStreamFormMixin, FormView): - turbo_stream_target = "form-target" - template_name = "my_form.html" - # action = Action.REPLACE -``` - -This mixin will automatically add the target name to the template context as *turbo_stream_target*. The partial template will be automatically resolved as the template name prefixed with an underscore: in this example, *_my_form.html*. You can also set it explicitly with the *turbo_stream_template_name* class attribute. The default action is "replace". - -As with the form mixin above, the package includes a number of view classes using this mixin: - -- **turbo_response.views.TurboStreamFormView** -- **turbo_response.views.TurboStreamCreateView** -- **turbo_response.views.TurboStreamUpdateView** - -So the above example could be rewritten as: - -```python -from turbo_response.views import TurboStreamFormView - -class MyView(TurboStreamFormView): - turbo_stream_target = "form-target" - template_name = "my_form.html" -``` - -The model-based classes automatically set the target DOM ID based on the model. The pattern for **TurboStreamCreateView** is *form-* and for **TurboStreamUpdateView** *form--*. You can override this by setting the *target* attribute explicitly or overriding the *get_turbo_stream_target* method. - -A final point re: forms: Turbo processes forms using the FormData API and only includes inputs with a value. This means all buttons, inputs etc. must have a value. For example suppose you have a button like this: - -```html - -``` - -If your view code checks for this value: - -```python -if "send_action" in request.POST: - ... -``` - -it will consistently fail. You should have something like: - -```html - -``` - -to ensure the FormData object includes the button value. - -## 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 might need to take a look at this [Clarification on redirect status code (303)](https://github.com/hotwired/turbo/issues/84#issuecomment-862656931) - -In Django, you can do it like this: - -```python -from django.shortcuts import redirect - -return redirect('https://example.com/', status=303) -``` - -## Responding with Multiple Streams - -Suppose you want to return **multiple** Turbo Streams in a single view. For example, let's say you are building a shopping cart for an e-commerce site. The shopping cart is presented as a list of items, and you can edit the amount in each and click a "Save" icon next to that amount. When the amount is changed, you want to recalculate the total cost of all the items and show this total at the bottom of the cart. In addition, there is a little counter on the top navbar that shows the same total across the whole site. - -You can return multiple streams either in a generator with **TurboStreamStreamingResponse** or pass an iterable to **TurboStreamResponse**. In either case, you must manually wrap each item in a `` tag. - -Taking the example above, we have a page with the shopping cart that has this snippet: - -```html -{{ total_amount }} -``` - -and in the navbar of our base template: - -```html -{{ total_amount }} -``` - -In both cases, the total amount is precalculated in the initial page load, for example using a context processor. - -Each item in the cart has an inline edit form that might look like this: - -```html - -
- {% csrf_token %} - - -
- -``` - -```python -from turbo_response import TurboStreamResponse, TurboStream - -def update_cart_item(request, item_id): - # item saved to e.g. session or db - save_cart_item(request, item_id) - - # for brevity, assume "total amount" is returned here as a - # correctly formatted string in the correct local currency - total_amount = calc_total_cart_amount(request) - - return TurboStreamResponse([ - TurboStream("nav-cart-total").replace.render(total_amount), - TurboStream("cart-summary-total").replace.render(total_amount), - ]) -``` - -Or using a generator: - -```python -from turbo_response import TurboStreamStreamingResponse, TurboStream - -def update_cart_item(request, item_id): - # item saved to e.g. session or db - save_cart_item(request, item_id) - - # for brevity, assume "total amount" is returned here as a - # correctly formatted string in the correct local currency - total_amount = calc_total_cart_amount(request) - - def render_response(): - yield TurboStream("nav-cart-total").replace.render(total_amount) - yield TurboStream("cart-summary-total").replace.render(total_amount) - return TurboStreamStreamingResponse(render_response()) -``` - -That's it! In this example, we are returning a very simple string value, so we don't need to wrap the responses in templates. - -Note that this technique is something of an anti-pattern; if you have to update multiple parts of a page, a full refresh (i.e., a normal Turbo visit) is probably a better idea. It's useful though in some edge cases where you need to avoid this. - -## The turbo_stream_response decorator - -You can accomplish the above using the **turbo_stream_response** decorator with your view. This will check the output and wrap the response in a **TurboStreamResponse** or **TurboStreamStreamingResponse**: - -```python -from turbo_response import TurboStream -from turbo_response.decorators import turbo_stream_response - -@turbo_stream_response -def update_cart_item(request, item_id): - # item saved to e.g. session or db - save_cart_item(request, item_id) - - # for brevity, assume "total amount" is returned here as a - # correctly formatted string in the correct local currency - total_amount = calc_total_cart_amount(request) - - return [ - TurboStream("nav-cart-total").replace.render(total_amount), - TurboStream("cart-summary-total").replace.render(total_amount), - ] -``` - -Or using *yield* statements: - -```python - @turbo_stream_response - def update_cart_item(request, item_id): - # item saved to e.g. session or db - save_cart_item(request, item_id) - - # for brevity, assume "total amount" is returned here as a - # correctly formatted string in the correct local currency - total_amount = calc_total_cart_amount(request) - - yield TurboStream("nav-cart-total").replace.render(total_amount) - yield TurboStream("cart-summary-total").replace.render(total_amount)A -``` - -If you return an HttpResponse subclass from your view (e.g. an HttpResponseRedirect, TemplateResponse, or a TurboStreamResponse) this will be ignored by the decorator and returned as normal. - -## Using Turbo Frames - -Turbo frames are straightforward using the **TurboFrame** class. - -For example, suppose we want to render some content inside a frame with the ID "content": - -```html -
-add something here! -``` - -The view looks like this: - -```python -def my_view(request): - return TurboFrame("content").response("hello") -``` - -As with streams, you can also render a template: - -```python -def my_view(request): - return TurboFrame("content").template("_content.html", {"message": "hello"}).response(request) -``` - -## Handling Lazy Turbo Frames - -Turbo Frames have a useful feature that allows `lazy loading `_. This is very easy to handle with Django. For example, our e-commerce site includes a list of recommendations at the bottom of some pages based on the customer's prior purchases. We calculate this list using our secret-sauce machine-learning algorithm. Although the results are cached for that user, the initial run can be a bit slow, and we don't want to slow down the rest of the page when the recommendations are recalculated. - -This is a good use case for a lazy turbo frame. Our template looks like this, with a fancy loading gif as a placeholder: - -```html - - - -``` - -And our corresponding view: - -```python -def recommendations(request): - # lazily build recommendations from algorithm and cache result - recommended_items = get_recommendations_from_cache(request.user) - return TurboFrame("recommendations").template( - "_recommendations.html", - {"items": recommended_items}, - ).response(request) -``` - -The template returned is just a plain Django template. The response class automatically wraps the correct tags, so we don't need to include ``. - -Note that adding *loading="lazy"* will defer loading until the frame appears in the viewport. - -```html -
- {% for item in items %} -

{{ item.title }}

- {% endfor %} -
-``` - -When the user visits this page, they will see the loading gif at the bottom of the page, replaced by the list of recommended products when that view is ready. - -## Channels - -This library can also be used with `django-channels `_. As with multiple streams, you can use the **TurboStream** class to broadcast turbo-stream content from your consumers. - -```python -from turbo_response import render_turbo_stream, render_turbo_stream_template -from channels.generic.websocket import AsyncJsonWebsocketConsumer - -class ChatConsumer(AsyncJsonWebsocketConsumer): - - async def chat_message(self, event): - - # DB methods omitted for brevity - message = await self.get_message(event["message"]["id"]) - num_unread_messages = await self.get_num_unread_messages() - - if message: - await self.send( - TurboStream("unread_message_counter") - .replace.render(str(num_unread_messages)) - ) - - await self.send( - TurboStream("messages").append.template( - "chat/_message.html", - {"message": message, "user": self.scope['user']}, - ).render() - ) -``` - -See the django-channels documentation for more details on setting up ASGI and channels. Note that you will need to set up your WebSockets in the client, for example in a Stimulus controller: - -```javascript -import { Controller } from 'stimulus'; -import { connectStreamSource, disconnectStreamSource } from '@hotwired/turbo'; - -export default class extends Controller { - static values = { - socketUrl: String, - }; - - connect() { - this.source = new WebSocket(this.socketUrlValue); - connectStreamSource(this.source); - } - - disconnect() { - if (this.source) { - disconnectStreamSource(this.source); - this.source.close(); - this.source = null; - } - } -} -``` - -**Note** if you want to add reactivity directly to your models, so that model changes broadcast turbo-streams automatically, we recommend the `turbo-django `_ package. - -## Hints on testing - -When testing, it's useful to be able to simulate Turbo headers. - -If you wish to test the result of a response within a Turbo frame, use the header **HTTP_TURBO_FRAME**: - -```python -from django.test import TestCase - -class TestViews(TestCase): - - def test_my_frame_view(self): - response = self.client.get("/", HTTP_TURBO_FRAME="some-dom-id") - self.assertEqual(response.status_code, 200) -``` - -To simulate the Turbo-Stream header, you should set **HTTP_ACCEPT**. - -```python -from django.test import TestCase -from turbo_response.constants import TURBO_STREAM_MIME_TYPE - -class TestViews(TestCase): - - def test_my_stream_view(self): - response = self.client.post("/", HTTP_ACCEPT=TURBO_STREAM_MIME_TYPE) - self.assertEqual(response.status_code, 200) -``` diff --git a/docs/source/original.md b/docs/source/original.md new file mode 100644 index 0000000..4d95d66 --- /dev/null +++ b/docs/source/original.md @@ -0,0 +1,247 @@ +# Render in Django View + +**This approach is in maintenance mode, and we recommend to use template tags instead** + +## TurboFrame and TurboStream + +A couple of classes, **TurboFrame** and **TurboStream**, provide the basic API for rendering streams and frames. + +To render plain strings: + +```python +from turbo_response import TurboFrame, TurboStream, Action + +# returns +TurboStream("msg").replace.render("OK") + +# returns +TurboStream(".msg", is_multiple=True).replace.render("OK") + +# set action dynamically +TurboStream("msg").action(Action.REPLACE).render("OK") + +# returns +TurboStream("msg").remove.render() + +# returns OK +TurboFrame("msg").render("OK") +``` + +You can also render templates: + +```python +TurboStream("msg").replace.template("msg.html", {"msg": "hello"}).render() + +TurboStream(".msg", is_multiple=True).replace.template("msg.html", {"msg": "hello"}).render() + +TurboFrame("msg").template("msg.html", {"msg": "hello"}).render() +``` + +You can also return an `HTTPResponse` subclass. The content type `text/html; turbo-stream;` will be added to turbo stream responses. + +```python +def my_stream(request): + return TurboStream("msg").replace.response("OK") + +def my_frame(request): + return TurboFrame("msg").response("OK") + +def my_tmpl_stream(request): + return TurboStream("msg").replace.template("msg.html", {"msg": "OK"}).response(request) + +def my_tmpl_frame(request): + return TurboFrame("msg").template("msg.html", {"msg": "OK"}).response(request) +``` + +**Note** if you are using the plain TurboStream or TurboFrame `render()` and `response()` non-template methods, any HTML will be automatically escaped. To prevent this pass `is_safe` (assuming you know the HTML is safe, of course): + +```python +TurboStream("msg").replace.render("OK", is_safe=True) + +TurboFrame("msg").response("OK", is_safe=True) +``` + +You don't need to do this with the template methods as HTML output is assumed: + +```python +TurboFrame("msg").template("msg.html", {"msg": "OK"}).response(request) +``` + +See the API docs for more details. + +## Responding with Multiple Streams + +Suppose you want to return **multiple** Turbo Streams in a single view. For example, let's say you are building a shopping cart for an e-commerce site. The shopping cart is presented as a list of items, and you can edit the amount in each and click a "Save" icon next to that amount. When the amount is changed, you want to recalculate the total cost of all the items and show this total at the bottom of the cart. In addition, there is a little counter on the top navbar that shows the same total across the whole site. + +You can return multiple streams either in a generator with **TurboStreamStreamingResponse** or pass an iterable to **TurboStreamResponse**. In either case, you must manually wrap each item in a `` tag. + +Taking the example above, we have a page with the shopping cart that has this snippet: + +```html +{{ total_amount }} +``` + +and in the navbar of our base template: + +```html +{{ total_amount }} +``` + +In both cases, the total amount is precalculated in the initial page load, for example using a context processor. + +Each item in the cart has an inline edit form that might look like this: + +```html + +
+ {% csrf_token %} + + +
+ +``` + +```python +from turbo_response import TurboStreamResponse, TurboStream + +def update_cart_item(request, item_id): + # item saved to e.g. session or db + save_cart_item(request, item_id) + + # for brevity, assume "total amount" is returned here as a + # correctly formatted string in the correct local currency + total_amount = calc_total_cart_amount(request) + + return TurboStreamResponse([ + TurboStream("nav-cart-total").replace.render(total_amount), + TurboStream("cart-summary-total").replace.render(total_amount), + ]) +``` + +Or using a generator: + +```python +from turbo_response import TurboStreamStreamingResponse, TurboStream + +def update_cart_item(request, item_id): + # item saved to e.g. session or db + save_cart_item(request, item_id) + + # for brevity, assume "total amount" is returned here as a + # correctly formatted string in the correct local currency + total_amount = calc_total_cart_amount(request) + + def render_response(): + yield TurboStream("nav-cart-total").replace.render(total_amount) + yield TurboStream("cart-summary-total").replace.render(total_amount) + return TurboStreamStreamingResponse(render_response()) +``` + +That's it! In this example, we are returning a very simple string value, so we don't need to wrap the responses in templates. + +Note that this technique is something of an anti-pattern; if you have to update multiple parts of a page, a full refresh (i.e., a normal Turbo visit) is probably a better idea. It's useful though in some edge cases where you need to avoid this. + +## The turbo_stream_response decorator + +You can accomplish the above using the **turbo_stream_response** decorator with your view. This will check the output and wrap the response in a **TurboStreamResponse** or **TurboStreamStreamingResponse**: + +```python +from turbo_response import TurboStream +from turbo_response.decorators import turbo_stream_response + +@turbo_stream_response +def update_cart_item(request, item_id): + # item saved to e.g. session or db + save_cart_item(request, item_id) + + # for brevity, assume "total amount" is returned here as a + # correctly formatted string in the correct local currency + total_amount = calc_total_cart_amount(request) + + return [ + TurboStream("nav-cart-total").replace.render(total_amount), + TurboStream("cart-summary-total").replace.render(total_amount), + ] +``` + +Or using *yield* statements: + +```python + @turbo_stream_response + def update_cart_item(request, item_id): + # item saved to e.g. session or db + save_cart_item(request, item_id) + + # for brevity, assume "total amount" is returned here as a + # correctly formatted string in the correct local currency + total_amount = calc_total_cart_amount(request) + + yield TurboStream("nav-cart-total").replace.render(total_amount) + yield TurboStream("cart-summary-total").replace.render(total_amount) +``` + +If you return an HttpResponse subclass from your view (e.g. an HttpResponseRedirect, TemplateResponse, or a TurboStreamResponse) this will be ignored by the decorator and returned as normal. + +## Using Turbo Frames + +Turbo frames are straightforward using the **TurboFrame** class. + +For example, suppose we want to render some content inside a frame with the ID "content": + +```html +
+add something here! +``` + +The view looks like this: + +```python +def my_view(request): + return TurboFrame("content").response("hello") +``` + +As with streams, you can also render a template: + +```python +def my_view(request): + return TurboFrame("content").template("_content.html", {"message": "hello"}).response(request) +``` + +## Handling Lazy Turbo Frames + +Turbo Frames have a useful feature that allows `lazy loading `_. This is very easy to handle with Django. For example, our e-commerce site includes a list of recommendations at the bottom of some pages based on the customer's prior purchases. We calculate this list using our secret-sauce machine-learning algorithm. Although the results are cached for that user, the initial run can be a bit slow, and we don't want to slow down the rest of the page when the recommendations are recalculated. + +This is a good use case for a lazy turbo frame. Our template looks like this, with a fancy loading gif as a placeholder: + +```html + + + +``` + +And our corresponding view: + +```python +def recommendations(request): + # lazily build recommendations from algorithm and cache result + recommended_items = get_recommendations_from_cache(request.user) + return TurboFrame("recommendations").template( + "_recommendations.html", + {"items": recommended_items}, + ).response(request) +``` + +The template returned is just a plain Django template. The response class automatically wraps the correct tags, so we don't need to include ``. + +Note that adding *loading="lazy"* will defer loading until the frame appears in the viewport. + +```html +
+ {% for item in items %} +

{{ item.title }}

+ {% endfor %} +
+``` + +When the user visits this page, they will see the loading gif at the bottom of the page, replaced by the list of recommended products when that view is ready. + diff --git a/docs/source/redirect.md b/docs/source/redirect.md new file mode 100644 index 0000000..f3fc6a3 --- /dev/null +++ b/docs/source/redirect.md @@ -0,0 +1,13 @@ +# 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 might need to take a look at this [Clarification on redirect status code (303)](https://github.com/hotwired/turbo/issues/84#issuecomment-862656931) + +In Django, you can do it like this: + +```python +from django.shortcuts import redirect + +return redirect('https://example.com/', status=303) +``` diff --git a/docs/source/template-tags.md b/docs/source/template-tags.md new file mode 100644 index 0000000..6202d7e --- /dev/null +++ b/docs/source/template-tags.md @@ -0,0 +1,89 @@ +# Template Tags + +Generate `turbo-frame` and `turbo-stream` from Django template is now the **recommended** way since it is more clean and easy to understand. + +## dom_id + +`dom_id` is a helper method that returns a unique DOM ID based on the object's class name and ID + +```html +{% load turbo_helper %} + +{% dom_id instance %} -> task_1 +{% dom_id instance 'detail' %} -> detail_task_1 +{% dom_id Task %} -> new_task +``` + +1. `dom_id` first argument can be string, instance or Model class +2. `dom_id` second argument is optional string that will be used as `prefix`. +3. The `dom_id` can help make the id generation behavior consistent across the project, and save our time to update it in `turbo-stream` or `turbo-frame` element. +4. You can also use it in your Django view code. + +## turbo_frame + +This tag can help us generate `turbo-frame` element in Django template. + +```html +{% load turbo_helper %} + +{% url 'message-create' as src %} +{% turbo_frame "message_create" src=src %} + Loading... +{% endturbo_frame %} +``` + +or you can use it with `dom_id` + +```html +{% load turbo_helper %} + +{% dom_id instance 'detail' as dom_id %} +{% turbo_frame dom_id class="flex-1" %} + {% include 'components/detail.html' %} +{% endturbo_frame %} +``` + +Notes: + +1. First argument is `turbo frame id` +2. Other arguments can be passed as `key=value` pairs + +## turbo_stream + +This tag can help us generate `turbo-stream` element in Django template. + +```html +{% load turbo_helper %} + +{% turbo_stream 'append' 'messages' %} + {% include 'core/components/message.html' %} +{% endturbo_stream %} + +{% turbo_stream 'update' 'new_task' %} + {% include 'components/create.html' %} +{% endturbo_stream %} +``` + +Notes: + +1. First argument is `turbo stream action` +2. Second argument is `turbo stream target` +3. Other arguments can be passed as `key=value` pairs +4. We can generate **multiple** turbo stream elements in one template and render it in one response, and update multiple part of the page in one response. + +You can return Turbo Stream resposne in Django view like this: + +```python +from turbo_response.response import TurboStreamResponse +from django.template.loader import render_to_string + +context = {} +html = render_to_string( + "partial/task_list.turbo_stream.html", + context, + request=self.request, +) +return TurboStreamResponse(html) +``` + +The code in Django view would be much cleaner and easier to maintain. diff --git a/docs/source/test.md b/docs/source/test.md new file mode 100644 index 0000000..b62de64 --- /dev/null +++ b/docs/source/test.md @@ -0,0 +1,28 @@ +# Hints on testing + +When testing, it's useful to be able to simulate Turbo headers. + +If you wish to test the result of a response within a Turbo frame, use the header **HTTP_TURBO_FRAME**: + +```python +from django.test import TestCase + +class TestViews(TestCase): + + def test_my_frame_view(self): + response = self.client.get("/", HTTP_TURBO_FRAME="some-dom-id") + self.assertEqual(response.status_code, 200) +``` + +To simulate the Turbo-Stream header, you should set **HTTP_ACCEPT**. + +```python +from django.test import TestCase +from turbo_response.constants import TURBO_STREAM_MIME_TYPE + +class TestViews(TestCase): + + def test_my_stream_view(self): + response = self.client.post("/", HTTP_ACCEPT=TURBO_STREAM_MIME_TYPE) + self.assertEqual(response.status_code, 200) +``` diff --git a/setup.cfg b/setup.cfg index a49b850..ccbe96c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [flake8] -ignore = E203, E266, E501, W503, E231, E701, B950 +ignore = E203, E266, E501, W503, E231, E701, B950, B907 max-line-length = 88 max-complexity = 18 select = B,C,E,F,W,T4,B9 diff --git a/src/turbo_response/__init__.py b/src/turbo_response/__init__.py index 1f5dcb7..3a24708 100644 --- a/src/turbo_response/__init__.py +++ b/src/turbo_response/__init__.py @@ -12,6 +12,7 @@ from .shortcuts import redirect_303, render_form_response from .stream import TurboStream from .template import render_turbo_frame_template, render_turbo_stream_template +from .templatetags.turbo_helper import dom_id __all__ = [ "Action", @@ -29,4 +30,5 @@ "render_turbo_frame_template", "render_turbo_stream", "render_turbo_stream_template", + "dom_id", ] diff --git a/src/turbo_response/apps.py b/src/turbo_response/apps.py new file mode 100644 index 0000000..077e662 --- /dev/null +++ b/src/turbo_response/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TurboResponseConfig(AppConfig): + name = "turbo_response" + verbose_name = "Turbo Response" diff --git a/src/turbo_response/templatetags/__init__.py b/src/turbo_response/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/turbo_response/templatetags/turbo_helper.py b/src/turbo_response/templatetags/turbo_helper.py new file mode 100644 index 0000000..81ea79d --- /dev/null +++ b/src/turbo_response/templatetags/turbo_helper.py @@ -0,0 +1,175 @@ +from typing import Any, Optional + +from django import template +from django.db.models.base import Model +from django.template import Node, TemplateSyntaxError, engines +from django.template.base import token_kwargs +from django.utils.safestring import mark_safe + +register = template.Library() + + +@register.simple_tag +def dom_id(instance: Any, prefix: Optional[str] = "") -> str: + if not isinstance(instance, type) and isinstance(instance, Model): + # instance + identifier = f"{instance.__class__.__name__.lower()}_{instance.id}" + elif isinstance(instance, type) and issubclass(instance, Model): + # model class + identifier = f"new_{instance.__name__.lower()}" + else: + identifier = str(instance) + + if prefix: + identifier = f"{prefix}_{identifier}" + + return identifier + + +class TurboFrameTagNode(Node): + def __init__(self, frame_id, nodelist, extra_context=None): + self.frame_id = frame_id + self.nodelist = nodelist + self.extra_context = extra_context or {} + + def __repr__(self): + return "<%s>" % self.__class__.__name__ + + def render(self, context): + children = self.nodelist.render(context) + + attributes = { + key: str(value.resolve(context)) + for key, value in self.extra_context.items() + } + element_attributes = {} + for key, value in attributes.items(): + # convert data_xxx to data-xxx + if key.startswith("data"): + element_attributes[key.replace("_", "-")] = value + else: + element_attributes[key] = value + + element_attributes_array = [] + for key, value in element_attributes.items(): + element_attributes_array.append(f'{key}="{value}"') + + attribute_string = mark_safe(" ".join(element_attributes_array)) + + django_engine = engines["django"] + template_string = """{{ children }}""" + context = { + "children": children, + "frame_id": self.frame_id.resolve(context), + "attribute_string": attribute_string, + } + return django_engine.from_string(template_string).render(context) + + +class TurboStreamTagNode(Node): + def __init__(self, action, target, nodelist, extra_context=None): + self.action = action + self.target = target + self.nodelist = nodelist + self.extra_context = extra_context or {} + + def __repr__(self): + return "<%s>" % self.__class__.__name__ + + def render(self, context): + children = self.nodelist.render(context) + + attributes = { + key: str(value.resolve(context)) + for key, value in self.extra_context.items() + } + element_attributes = {} + for key, value in attributes.items(): + # convert data_xxx to data-xxx + if key.startswith("data"): + element_attributes[key.replace("_", "-")] = value + else: + element_attributes[key] = value + + element_attributes_array = [] + for key, value in element_attributes.items(): + element_attributes_array.append(f'{key}="{value}"') + + attribute_string = mark_safe(" ".join(element_attributes_array)) + + django_engine = engines["django"] + template_string = """""" + context = { + "children": children, + "action": self.action.resolve(context), + "target": self.target.resolve(context), + "attribute_string": attribute_string, + } + return django_engine.from_string(template_string).render(context) + + +@register.tag("turbo_frame") +def turbo_frame_tag(parser, token): + args = token.split_contents() + + if len(args) < 2: + raise TemplateSyntaxError( + "'turbo_frame' tag requires at least one argument to set the id" + ) + + frame_id = parser.compile_filter(args[1]) + + # Get all elements of the list except the first one + remaining_bits = args[2:] + + # Parse the remaining bits as keyword arguments + extra_context = token_kwargs(remaining_bits, parser, support_legacy=True) + + # If there are still remaining bits after parsing the keyword arguments, + # raise an exception indicating that an invalid token was received + if remaining_bits: + raise TemplateSyntaxError( + "%r received an invalid token: %r" % (args[0], remaining_bits[0]) + ) + + # Parse the content between the start and end tags + nodelist = parser.parse(("endturbo_frame",)) + + # Delete the token that triggered this function from the parser's token stream + parser.delete_first_token() + + return TurboFrameTagNode(frame_id, nodelist, extra_context=extra_context) + + +@register.tag("turbo_stream") +def turbo_stream_tag(parser, token): + args = token.split_contents() + + if len(args) < 3: + raise TemplateSyntaxError( + "'turbo_stream' tag requires two arguments, first is action, second is the target_id" + ) + + action = parser.compile_filter(args[1]) + target = parser.compile_filter(args[2]) + + # Get all elements of the list except the first one + remaining_bits = args[3:] + + # Parse the remaining bits as keyword arguments + extra_context = token_kwargs(remaining_bits, parser, support_legacy=True) + + # If there are still remaining bits after parsing the keyword arguments, + # raise an exception indicating that an invalid token was received + if remaining_bits: + raise TemplateSyntaxError( + "%r received an invalid token: %r" % (args[0], remaining_bits[0]) + ) + + # Parse the content between the start and end tags + nodelist = parser.parse(("endturbo_stream",)) + + # Delete the token that triggered this function from the parser's token stream + parser.delete_first_token() + + return TurboStreamTagNode(action, target, nodelist, extra_context=extra_context) diff --git a/tests/test_tags.py b/tests/test_tags.py new file mode 100644 index 0000000..277a2d2 --- /dev/null +++ b/tests/test_tags.py @@ -0,0 +1,103 @@ +import pytest +from django.template import Context, Template + +from tests.testapp.models import TodoItem +from turbo_response.templatetags.turbo_helper import dom_id + +pytestmark = pytest.mark.django_db + + +def render(template, context): + return Template(template).render(Context(context)) + + +class TestDomId: + def test_instance(self, todo): + result = dom_id(todo) + assert "todoitem_1" == result + + def test_model(self): + result = dom_id(TodoItem) + assert "new_todoitem" == result + + def test_string(self): + result = dom_id("test") + assert "test" == result + + def test_prefix(self, todo): + result = dom_id(todo, "test") + assert "test_todoitem_1" == result + + +class TestFrame: + def test_string(self): + template = """ + {% load turbo_helper %} + + {% turbo_frame "test" %}Loading...{% endturbo_frame %} + """ + output = render(template, {}).strip() + assert output == 'Loading...' + + def test_dom_id_variable(self): + template = """ + {% load turbo_helper %} + + {% turbo_frame dom_id %}Loading...{% endturbo_frame %} + """ + output = render(template, {"dom_id": "test"}).strip() + assert output == 'Loading...' + + def test_src(self): + template = """ + {% load turbo_helper %} + + {% turbo_frame dom_id src=src %}Loading...{% endturbo_frame %} + """ + output = render( + template, {"dom_id": "test", "src": "http://localhost:8000"} + ).strip() + assert ( + output + == 'Loading...' + ) + + def test_other_attributes(self): + template = """ + {% load turbo_helper %} + + {% turbo_frame dom_id src=src lazy="loading" %}Loading...{% endturbo_frame %} + """ + output = render( + template, {"dom_id": "test", "src": "http://localhost:8000"} + ).strip() + assert ( + output + == 'Loading...' + ) + + +class TestStream: + def test_string(self): + template = """ + {% load turbo_helper %} + + {% turbo_stream "append" 'test' %}Test{% endturbo_stream %} + """ + output = render(template, {}).strip() + assert ( + output + == '' + ) + + def test_dom_id_variable(self): + template = """ + {% load turbo_helper %} + + {% turbo_stream "append" dom_id %}Test{% endturbo_stream %} + """ + output = render(template, {"dom_id": "test"}).strip() + assert ( + output + == '' + )