Skip to content
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

pre 2.1.3 #42

Merged
merged 8 commits into from
Jan 26, 2024
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [2.1.3]

1. Work with `Turbo 8 morph-refreshes`
2. Add decorator `after_create_commit, after_update_commit, after_destroy_commit`.
3. Add `broadcast_action_to`, `broadcast_refresh_to` to broadcast action to the channels.

## [2.1.2]

1. Update to work with django-actioncable>=1.0.4
Expand Down
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ Topics
real-time-updates.md
extend-turbo-stream.md
multi-format.md
signal-decorator.md
redirect.md
test.md
32 changes: 24 additions & 8 deletions docs/source/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
```{note}
This package does not include any Javascript library, you may wish to add these yourself using your preferred Javascript build tool, or use a CDN.

Please check <a href="https://turbo.hotwired.dev/handbook/installing" target="_blank">Hotwire Doc</a>
Please check <a href="https://turbo.hotwired.dev/handbook/installing" target="_blank">Hotwire Doc</a> for more details
```

## Requirements
Expand All @@ -13,7 +13,7 @@ This library requires Python 3.8+ and Django 3.2+.
## Getting Started

```shell
pip install django-turbo-helper
$ pip install django-turbo-helper
```

Next update **INSTALLED_APPS**:
Expand All @@ -27,21 +27,37 @@ INSTALLED_APPS = [

## Middleware

You can optionally install `turbo_helper.middleware.TurboMiddleware`. This adds the attribute `turbo` to your `request`.
Add `turbo_helper.middleware.TurboMiddleware` to the `MIDDLEWARE` in Django settings file.

```python
MIDDLEWARE = [
...
"turbo_helper.middleware.TurboMiddleware",
"django.middleware.common.CommonMiddleware",
"turbo_helper.middleware.TurboMiddleware", # new
...
]
```

With the `TurboMiddleware` we have `request.turbo` object which we can access in Django view or template.

If the request originates from a turbo-frame, we can get the value from the `request.turbo.frame`

```django
{% turbo_frame request.turbo.frame %}
{% include 'template.html' %}
{% endturbo_frame %}
{% load turbo_helper %}

{% if request.turbo.frame %}

{% turbo_frame request.turbo.frame %}
{% include 'template.html' %}
{% endturbo_frame %}

{% endif %}
```

Or we can use `request.turbo.accept_turbo_stream` to check if the request accepts turbo stream response.

```python
if request.turbo.accept_turbo_stream:
# return turbo stream response
else:
# return normal HTTP response
```
10 changes: 4 additions & 6 deletions docs/source/multi-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,10 @@ class TaskCreateView(LoginRequiredMixin, CreateView):

Notes:

1. If the browser accepts HTML, we return HTML response.
2. If the browser accepts turbo stream, we return turbo stream response.
3. This is useful when we want to migrate our Django app from normal web page to turbo stream gradually.
1. If the browser accepts turbo stream (`Accept` header should **explicitly contain** `text/vnd.turbo-stream.html`), we return turbo stream response.
2. If the browser accepts HTML (`*/*` in `Accept` also work), we return HTML response.
3. This is useful when we want to **migrate our Django app from normal web page to turbo stream gradually**.

```{note}
Most browsers send Accept: `*/*` by default, so this would return True for all content types.

To avoid problem, it is recommned to put resp_format.html logic at the top since the order matters.
Please **put the non html response before html response**, and use html response as the fallback response.
```
46 changes: 43 additions & 3 deletions docs/source/real-time-updates.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Update Page in Real Time via Websocket
# Real Time Updates via Websocket

Use Websocket and Turbo Stream to update the web page in real time, without writing Javascript.

Expand All @@ -16,7 +16,7 @@ To import `turbo-cable-stream-source` element to the frontend, there are two way

Or you can [Jump start frontend project bundled by Webpack](https://github.com/AccordBox/python-webpack-boilerplate#jump-start-frontend-project-bundled-by-webpack) and install it via `npm install`

After frontend work is done, to support Actioncable on the server, please install [django-actioncable](https://github.com/AccordBox/django-actioncable).
After frontend is setup, to support Actioncable protocol on the server side, please install [django-actioncable](https://github.com/AccordBox/django-actioncable).

In `routing.py`, register `TurboStreamCableChannel`

Expand All @@ -27,7 +27,7 @@ from turbo_helper.channels.streams_channel import TurboStreamCableChannel
cable_channel_register(TurboStreamCableChannel)
```

In Django template, we can subscribe to stream source like this:
In Django template, we can subscribe to stream source like this, it has nearly the same syntax as Rails `turbo_stream_from`:

```html
{% load turbo_helper %}
Expand Down Expand Up @@ -57,3 +57,43 @@ broadcast_render_to(
2. `keyword arguments` `template` and `context` are used to render the template.

The web page can be updated in real time, through Turbo Stream over Websocket.

## Broadcasts

### broadcast_action_to

Under `turbo_helper.channels.broadcasts`, there are some other helper functions to broadcast Turbo Stream to the stream source, just like Rails:

```python
def broadcast_action_to(*streamables, action, target=None, targets=None, **kwargs):
```

The `broadcast_action_to` function is inspired by Rails and is designed to facilitate broadcasting actions to multiple streamable objects. It accepts a variable number of streamables as arguments, which represent the objects that will receive the broadcasted actions.

The function requires an `action` parameter, which specifies the type of action to be performed.

Example:

```python
broadcast_action_to(
"chat",
instance.chat_id,
action="append",
template="message_content.html",
context={
"instance": instance,
},
target=dom_id(instance.chat_id, "message_list"),
)
```

### broadcast_refresh_to

This is for Rails 8 refresh action, and it would broadcast something like this via the websocket to trigger the page refresh:

```html
<turbo-stream
request-id="ca519ab9-1138-4625-abc2-6049317321a9"
action="refresh">
</turbo-stream>
```
32 changes: 32 additions & 0 deletions docs/source/signal-decorator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Signal Decorator

In Django, developer usually use `post_save` signal to perform certain actions after a model instance is saved.

Even `created` parameter indicates whether the instance is newly created or an existing one, this is not that straightforward.

With `turbo_helper`, we provide **syntax suger** to make it more clear, just like Rails.

```python
from turbo_helper import after_create_commit, after_update_commit, after_delete_commit


@after_create_commit(sender=Message)
def create_message_content(sender, instance, created, **kwargs):
broadcast_action_to(
"chat",
instance.chat_id,
action="append",
template="demo_openai/message_content.html",
context={
"instance": instance,
},
target=dom_id(instance.chat_id, "message_list"),
)
```

Notes:

1. `after_create_commit`, `after_update_commit`, `after_delete_commit`, are decorators, they are used to decorate a function, which will be called after the model instance is created, updated or deleted.
2. The function decorated by `after_create_commit`, `after_update_commit`, receive the same arguments as `post_save` signal handler.
3. The function decorated by `after_delete_commit` receive the same arguments as `post_delete` signal handler.
4. This can make our code more clear, especially when we need to some broadcasts.
7 changes: 6 additions & 1 deletion docs/source/turbo_stream.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ turbo_stream.append(
)
```

Turbo Stream built-in actions are supported in syntax `turbo_stream.xxx`:
Notes:

1. `request`, `context` are optional
2. If `content` is not set, then `template` is required to render the `content`.

Turbo Stream built-in actions are all supported in syntax `turbo_stream.xxx`:

- append
- prepend
Expand Down
7 changes: 0 additions & 7 deletions tests/templates/_my_form.html

This file was deleted.

14 changes: 0 additions & 14 deletions tests/templates/base.html

This file was deleted.

4 changes: 0 additions & 4 deletions tests/templates/my_form.html

This file was deleted.

7 changes: 0 additions & 7 deletions tests/templates/testapp/_todoitem_form.html

This file was deleted.

4 changes: 0 additions & 4 deletions tests/templates/testapp/todoitem_form.html

This file was deleted.

5 changes: 5 additions & 0 deletions tests/templates/todoitem.turbo_stream.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{% load turbo_helper %}

{% turbo_stream 'append' 'todo_list' %}
<div>{{ instance.description }}</div>
{% endturbo_stream %}
114 changes: 114 additions & 0 deletions tests/test_broadcasts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import unittest
from unittest import mock

import pytest

import turbo_helper.channels.broadcasts
from tests.testapp.models import TodoItem
from tests.utils import assert_dom_equal
from turbo_helper import dom_id
from turbo_helper.channels.broadcasts import (
broadcast_action_to,
broadcast_render_to,
broadcast_stream_to,
)

pytestmark = pytest.mark.django_db


class TestBroadcastStreamTo:
def test_broadcast_stream_to(self, monkeypatch):
mock_cable_broadcast = mock.MagicMock(name="cable_broadcast")
monkeypatch.setattr(
turbo_helper.channels.broadcasts, "cable_broadcast", mock_cable_broadcast
)

################################################################################

broadcast_stream_to("test", content="hello world")

mock_cable_broadcast.assert_called_with(
group_name="test", message="hello world"
)

################################################################################
todo_item = TodoItem.objects.create(description="Test Model")

broadcast_stream_to(todo_item, content="hello world")

mock_cable_broadcast.assert_called_with(
group_name=dom_id(todo_item), message="hello world"
)

################################################################################
todo_item = TodoItem.objects.create(description="Test Model")

broadcast_stream_to(todo_item, "test", content="hello world")

mock_cable_broadcast.assert_called_with(
group_name=f"{dom_id(todo_item)}_test", message="hello world"
)


class TestBroadcastActionTo:
def test_broadcast_action_to(self, monkeypatch):
mock_cable_broadcast = mock.MagicMock(name="cable_broadcast")
monkeypatch.setattr(
turbo_helper.channels.broadcasts, "cable_broadcast", mock_cable_broadcast
)

################################################################################

broadcast_action_to("tasks", action="remove", target="new_task")

assert mock_cable_broadcast.call_args.kwargs["group_name"] == "tasks"
assert_dom_equal(
mock_cable_broadcast.call_args.kwargs["message"],
'<turbo-stream action="remove" target="new_task"><template></template></turbo-stream>',
)

################################################################################
todo_item = TodoItem.objects.create(description="Test Model")

broadcast_action_to(todo_item, action="remove", target="new_task")

mock_cable_broadcast.assert_called_with(
group_name=dom_id(todo_item), message=unittest.mock.ANY
)

################################################################################
todo_item = TodoItem.objects.create(description="Test Model")

broadcast_action_to(todo_item, "test", action="remove", target="new_task")

mock_cable_broadcast.assert_called_with(
group_name=f"{dom_id(todo_item)}_test", message=unittest.mock.ANY
)


class TestBroadcastRenderTo:
def test_broadcast_render_to(self, monkeypatch):
mock_cable_broadcast = mock.MagicMock(name="cable_broadcast")
monkeypatch.setattr(
turbo_helper.channels.broadcasts, "cable_broadcast", mock_cable_broadcast
)

################################################################################
todo_item = TodoItem.objects.create(description="test")

broadcast_render_to(
todo_item,
template="todoitem.turbo_stream.html",
context={
"instance": todo_item,
},
)

mock_cable_broadcast.assert_called_with(
group_name=dom_id(todo_item), message=unittest.mock.ANY
)

assert_dom_equal(
mock_cable_broadcast.call_args.kwargs["message"],
'<turbo-stream action="append" target="todo_list"><template><div>test</div></template></turbo-stream>',
)
Loading
Loading