Skip to main content

Hotwired/Turbo response helpers for Django

Project description

This package provides helpers for server-side rendering of Hotwired/Turbo streams and frames.

Disclaimer: the Hotwired/Turbo client libraries are, at time of writing, still in Beta. We expect there will be breaking changes until the first stable release. This package, and the Turbo client, should therefore be used with caution in a production environment. The version used in testing is @hotwired/turbo==7.0.0-beta.1.

Requirements

This library is tested for Python 3.8+.

Getting Started

pip install django-turbo-response

To install from Git:

git clone https://github.com/danjac/django-turbo-response

cd django-turbo-response

python setup.py install

Note: This library does not include any client libraries (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.TurboStreamMiddleware. This adds the attribute accept_turbo_stream to your request if the Turbo client adds Accept: text/html; turbo-stream; to the header:

MIDDLEWARE = [
    ...
    "turbo_response.middleware.TurboStreamMiddleware",
    "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:

if request.accept_turbo_stream:
    return TurboStreamResponse(action=Action.REPLACE, target="item")
else:
    return redirect("index")

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

This pattern however does not work with Turbo. If you return HTML from a form post, a Javascript error will be thrown by the Turbo Drive library as it only accepts a redirect. Furthermore, you cannot exempt a form with the data-turbo=”false” attribute as you can with links. Both of these issues, as of writing, are being addressed by the Hotwire team and should be fixed in a future release. In the meantime, the acceptable approach for “traditional” form validation is:

  1. Render the initial form. The form, or surrounding tag, must have an “id”.

  2. Validate on POST as usual

  3. If any validation errors, pass back an HTML fragment containing the errors and user input in a Turbo Stream response with a target matching the HTML id and a “replace” or “update” action.

  4. If no validation errors, save and redirect as usual.

As an example, let’s take a typical function-based view (FBV):

from django.contrib.auth.decorators import login_required
from django.template.response import TemplateResponse

from myapp.todos.forms import TodoForm

@login_required
def create_todo(request):
    if request.method == "POST":
        form = TodoForm(request.POST)
        if form.is_valid():
            instance = form.save(commit=False)
            instance.owner = request.user
            instance.save()
    else:
        form = TodoForm()

    return TemplateResponse(request, "todos/todo_form.html", {"form": form})

Our template todos/todo_form.html looks something like this:

{% extends "base.html" %}
{% block content %}
<h1>Add your todo here!</h1>
<form method="post" action="{% url 'todos:create_todo' %}">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">Save</button>
</form>
{% endblock content %}

To make this work with Turbo, you would have to make these changes:

from django.contrib.auth.decorators import login_required
from django.template.response import TemplateResponse

from turbo_response import Action, TurboStreamTemplateResponse

from myapp.todos.forms import TodoForm

@login_required
def create_todo(request):
    if request.method == "POST":
        form = TodoForm(request.POST)
        if form.is_valid():
            instance = form.save(commit=False)
            instance.owner = request.user
            instance.save()
        # return the invalid form in a stream
        return TurboStreamTemplateResponse(
            request,
            "todos/_todo_form.html",
            {
                "form": form,
            },
            action=Action.REPLACE,
            target="todo-form",
          )

    else:
        form = TodoForm()
    return TemplateResponse(request, "todos/todo_form.html", {"form": form})

We break up our todo_form.html template, extracting the HTML into a partial include containing the form. A common convention is to use an initial underscore to distinguish partial templates but you can use any naming scheme you wish:

todos/todo_form.html

{% extends "base.html" %}
{% block content %}
<h1>Add your todo here!</h1>
{% include "todos/_todo_form.html" %}
{% endblock content %}

todos/_todo_form.html

<form method="post" action="{% url 'todos:create_todo' %}" id="todo-form">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">Save</button>
</form>

Notice the ID on the form tag. In addition, the template must render with a single top-level tag.

If the form contains errors, the response should look something like this:

<turbo-stream action="replace" target="todo-form">
  <template>
    <form method="post" ...>
    form body with error messages goes here...
    </form>
  </template>
</turbo-stream>

If you prefer class-based views (CBVs) you can do the same with the TurboStreamFormMixin:

from django.contrib.auth.mixins import LoginRequired
from django.views.generic.edit import CreateView

from turbo_response.mixins import TurboStreamFormMixin

from myapp.todos.forms import TodoForm
from myapp.todos.models import Todo


class CreateTodoView(TurboStreamFormMixin, CreateView):
    template_name = "todos/todo_form.html"
    turbo_stream_template_name = "todos/_todo_form.html"
    turbo_stream_target = "todo-form"

create_todo_view = CreateTodoView.as_view()

Note that the default target for this mixin is “replace”, so you don’t need to set it here.

To save typing you can just use TurboStreamCreateView:

from django.contrib.auth.mixins import LoginRequired
from django.views.generic.edit import CreateView

from turbo_response.views import TurboStreamCreateView

from myapp.todos.forms import TodoForm
from myapp.todos.models import Todo

class CreateTodoView(TurboStreamCreateView):
    model = Todo
    form_class = TodoForm
    template_name = "todos/todo_form.html"
    turbo_stream_template_name = "todos/_todo_form.html"
    turbo_stream_target = "todo-form"

create_todo_view = CreateTodoView.as_view()

This class automatically adopts the convention of using the underscore prefix for any partials, so you could save a couple lines of code and just write:

class CreateTodoView(TurboStreamCreateView):
    model = Todo
    form_class = TodoForm

and the turbo stream template will automatically resolve to todos/_todo_form.html (the CreateView of course resolves the default template names as well, based on the model metadata). The turbo-stream target is auto-generated based on the model meta info: in this case myapp-todo-form. If we use TurboStreamUpdateView then it will be myapp-todo-<todo_id>-form. If you don’t want your DOM IDs autogenerated, just set turbo_stream_target explictly in your view, or override get_turbo_stream_target().

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 which shows the same total across the whole site.

To do this you can use django.http.StreamingHttpResponse with a generator. The generator should yield each individual turbo-stream string. To ensure the correct content type is used, this package provides a subclass, turbo_response.TurboStreamStreamingResponse.

Taking the example above, we have a page with the shopping cart, that has this snippet:

<span id="cart-summary-total">{{ total_amount }}</span>

and in the navbar of our base template:

<span id="nav-cart-total">{{ total_amount }}</span>

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:

<td>
    <form method="post" action="{% url 'update_cart_item' item.id %}">
        {% csrf_token %}
        <input type="text" name="amount" value="{{ item.value }}">
        <button type="submit">Save</button>
    </form>
</td>
from turbo_response import Action, TurboStreamStreamingResponse, render_turbo_stream

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 render_turbo_stream(
            total_amount,
            action=Action.REPLACE,
            target="nav-cart-total"
            )

        yield render_turbo_stream(
            total_amount,
            action=Action.REPLACE,
            target="cart-summary-total"
            )
    return TurboStreamStreamingResponse(render_response())

That’s it! In this example are returning a very simple string value, so we don’t need to wrap the responses in templates. If you want to do so, use turbo_response.render_stream_template instead.

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.

Using Turbo Frames

Rendering Turbo Frames is straightforward. Let’s say you have a “Subscribe” button in your page. When the button is clicked, you want the “Subscribe” label to be changed to “Unsubscribe”; when the button is clicked again it should turn back to “Subscribe.”

Our template looks something like this:

{% extends "base.html" %}
{% block content %}
<h1>Welcome to my blog</h1>
{{ blog.description }}
{% if user.is_authenticated %}
<turbo-frame id="subscribe">
  {% include "_subscribe.html" %}
</turbo-frame>
{% endif %}
{% endblock %}

Note that we surround the partial template with the <turbo-frame> tags. These will be replaced by Turbo when a Turbo Frame response matching the DOM ID “subscribe” is returned from the server.

Our partial template, _subscribe.html looks like this:

<form method="post" action="{% url 'toggle_subscribe' blog.id %}">
  {% csrf_token %}
  <button>{{ is_subscribed|yesno:"Unsubscribe,Subscribe" }}</button>
</form>

Note that the button uses a POST form to handle the toggle. As it’s a POST we also need to include the CSRF token, or we’ll get a 403 error.

Here are the views:

from django.contrib.auth.decorators import login_required
from django.template.response import TemplateResponse
from django.shortcuts import get_object_or_404

from turbo_response import TurboFrameResponse

from myapp.blogs.models import Blog

def blog_detail(request, blog_id):
    blog = get_object_or_404(Blog, pk=blog_id)
    is_subscribed = blog.is_subscribed(request.user)
    return TemplateResponse(
        request,
        "blogs/detail.html",
        {"blog": blog, "is_subscribed": is_subscribed}
    )

@login_required
def subscribe(request, blog_id):
    blog = get_object_or_404(Blog, pk=blog_id)
    is_subscribed = blog.toggle_subscribe(request.user)
    return TurboFrameResponse(
         request,
        "blogs/_subscribe.html",
        {"blog": blog, "is_subscribed": is_subscribed},
        dom_id="subscribe",
    )

The subscribe view returns a response wrapped in the <turbo-frame> tag with the DOM id “subscribe”. Turbo will look for a corresponding frame in the HTML body with the matching ID, and replace the frame with the one returned from the server. Unlike a full Turbo visit, we don’t need to return the entire body - just the snippet we want to update.

If we wanted to use CBVs instead:

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.detail import DetailView, SingleObjectMixin

from turbo_response.views import TurboFrameTemplateView

from myapp.blogs.models import Blog

class BlogDetail(DetailView):
    model = Blog
    template_name = "blogs/detail.html"

    def get_context_data(self, **context):
        return {
            **context,
            "is_subscribed": blog.is_subscribed(request.user)
        }

class Subscribe(LoginRequiredMixin,
                SingleObjectMixin,
                TurboFrameTemplateView):

  turbo_frame_dom_id = "subscribe"
  template_name = "blogs/_subscribe.html"

  def post(request, pk):
      blog = self.get_object()
      is_subscribed = blog.toggle_subscribe(request.user)

      return self.render_to_response(
          {"blog": blog, "is_subscribed": is_subscribed},
      )

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:

<turbo-frame id="recommendations" src="{% url 'recommendations' %}">
    <img src="{% static 'fancy-loader.gif' %}">
</turbo-frame>

And our corresponding view:

def recommendations(request):
    # lazily build recommendations from algorithm and cache result
    recommended_items = get_recommendations_from_cache(request.user)
    return TurboFrameTemplateResponse(
        request,
        "_recommendations.html",
        {"items": recommended_items},
        dom_id="recommendations",
    )

The template returned is just a plain Django template. The response class automatically wraps the correct tags, so we don’t need to include <turbo-frame>.

<div class="recommendations">
    {% for item in items %}
    <h3><a href="{{ item.get_absolute_url }}">{{ item.title }}</a></h3>
    {% endfor %}
</div>

Channels

This library can also be used with django-channels Consumers with the helper functions render_turbo_stream and render_turbo_stream_template when broadcasting streams:

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(
                render_turbo_stream(
                    str(num_unread_messages),
                    action=Action.REPLACE,
                    target="unread_message_counter"
                )

            await self.send(
                render_turbo_stream_template(
                    "chat/_message.html",
                    {"message": message, "user": self.scope['user']},
                    action=Action.APPEND,
                    target="messages",
                )
            )

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:

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() {
    disconnectStreamSource(this.source);
    this.source = null;
  }
}

License

This project is covered by the MIT license.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django-turbo-response-0.0.1.tar.gz (13.9 kB view hashes)

Uploaded Source

Built Distribution

django_turbo_response-0.0.1-py3-none-any.whl (12.9 kB view hashes)

Uploaded Python 3

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page