diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3aa47d6..8b34408 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/source/index.rst b/docs/source/index.rst
index a89f65b..c171cb1 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -23,5 +23,6 @@ Topics
real-time-updates.md
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 6a181a1..ddf52bc 100644
--- a/docs/source/install.md
+++ b/docs/source/install.md
@@ -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 Hotwire Doc
+Please check Hotwire Doc for more details
```
## Requirements
@@ -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**:
@@ -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
```
diff --git a/docs/source/multi-format.md b/docs/source/multi-format.md
index 194d3fc..3557f33 100644
--- a/docs/source/multi-format.md
+++ b/docs/source/multi-format.md
@@ -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.
```
diff --git a/docs/source/real-time-updates.md b/docs/source/real-time-updates.md
index 15ff75c..28f8b35 100644
--- a/docs/source/real-time-updates.md
+++ b/docs/source/real-time-updates.md
@@ -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.
@@ -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`
@@ -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 %}
@@ -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
+
+
+```
diff --git a/docs/source/signal-decorator.md b/docs/source/signal-decorator.md
new file mode 100644
index 0000000..36e87b5
--- /dev/null
+++ b/docs/source/signal-decorator.md
@@ -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.
diff --git a/docs/source/turbo_stream.md b/docs/source/turbo_stream.md
index 6528ac4..b1329bb 100644
--- a/docs/source/turbo_stream.md
+++ b/docs/source/turbo_stream.md
@@ -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
diff --git a/tests/templates/_my_form.html b/tests/templates/_my_form.html
deleted file mode 100644
index 39b9e0d..0000000
--- a/tests/templates/_my_form.html
+++ /dev/null
@@ -1,7 +0,0 @@
-
diff --git a/tests/templates/base.html b/tests/templates/base.html
deleted file mode 100644
index 5af9217..0000000
--- a/tests/templates/base.html
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
- test page
- {% block content %}{% endblock %}
-
-
-
diff --git a/tests/templates/my_form.html b/tests/templates/my_form.html
deleted file mode 100644
index d52bfaf..0000000
--- a/tests/templates/my_form.html
+++ /dev/null
@@ -1,4 +0,0 @@
-{% extends "base.html" %}
-{% block content %}
-{% include "_my_form.html" %}
-{% endblock %}
diff --git a/tests/templates/testapp/_todoitem_form.html b/tests/templates/testapp/_todoitem_form.html
deleted file mode 100644
index 04cdf6a..0000000
--- a/tests/templates/testapp/_todoitem_form.html
+++ /dev/null
@@ -1,7 +0,0 @@
-
diff --git a/tests/templates/testapp/todoitem_form.html b/tests/templates/testapp/todoitem_form.html
deleted file mode 100644
index d52bfaf..0000000
--- a/tests/templates/testapp/todoitem_form.html
+++ /dev/null
@@ -1,4 +0,0 @@
-{% extends "base.html" %}
-{% block content %}
-{% include "_my_form.html" %}
-{% endblock %}
diff --git a/tests/templates/todoitem.turbo_stream.html b/tests/templates/todoitem.turbo_stream.html
new file mode 100644
index 0000000..5f95758
--- /dev/null
+++ b/tests/templates/todoitem.turbo_stream.html
@@ -0,0 +1,5 @@
+{% load turbo_helper %}
+
+{% turbo_stream 'append' 'todo_list' %}
+ {{ instance.description }}
+{% endturbo_stream %}
diff --git a/tests/test_broadcasts.py b/tests/test_broadcasts.py
new file mode 100644
index 0000000..0d7b3c9
--- /dev/null
+++ b/tests/test_broadcasts.py
@@ -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"],
+ '',
+ )
+
+ ################################################################################
+ 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"],
+ 'test
',
+ )
diff --git a/tests/test_shortcuts.py b/tests/test_shortcuts.py
index a3e6350..2ece3ff 100644
--- a/tests/test_shortcuts.py
+++ b/tests/test_shortcuts.py
@@ -2,7 +2,7 @@
import pytest
-from turbo_helper.shortcuts import redirect_303
+from turbo_helper.shortcuts import redirect_303, respond_to
pytestmark = pytest.mark.django_db
@@ -22,3 +22,38 @@ def test_model(self, todo):
resp = redirect_303(todo)
assert resp.status_code == http.HTTPStatus.SEE_OTHER
assert resp.url == f"/todos/{todo.id}/"
+
+
+class TestResponseTo:
+ def test_response_to(self, rf):
+ req = rf.get("/", HTTP_ACCEPT="*/*")
+ with respond_to(req) as resp:
+ """
+ wildcard only work for HTML
+ """
+ assert resp.html
+ assert not resp.turbo_stream
+ assert not resp.json
+
+ req = rf.get("/", HTTP_ACCEPT="text/vnd.turbo-stream.html")
+ with respond_to(req) as resp:
+ assert resp.turbo_stream
+ assert not resp.html
+ assert not resp.json
+
+ req = rf.get(
+ "/", HTTP_ACCEPT="text/html; charset=utf-8, application/json; q=0.9"
+ )
+ with respond_to(req) as resp:
+ assert not resp.turbo_stream
+ assert resp.html
+ assert resp.json
+
+ req = rf.get(
+ "/",
+ HTTP_ACCEPT="text/vnd.turbo-stream.html, text/html, application/xhtml+xml",
+ )
+ with respond_to(req) as resp:
+ assert resp.turbo_stream
+ assert resp.html
+ assert not resp.json
diff --git a/tests/test_signal_handler.py b/tests/test_signal_handler.py
new file mode 100644
index 0000000..46a5580
--- /dev/null
+++ b/tests/test_signal_handler.py
@@ -0,0 +1,90 @@
+import pytest
+
+from tests.testapp.models import TodoItem
+from turbo_helper.signals import (
+ after_create_commit,
+ after_delete_commit,
+ after_update_commit,
+)
+
+pytestmark = pytest.mark.django_db
+
+
+class TestSignalHandler:
+ def test_after_create_commit_signal_handler(self):
+ handler_called_1 = False
+
+ def handler_func_1(sender, instance, created, **kwargs):
+ nonlocal handler_called_1
+ handler_called_1 = True
+
+ handler_called_2 = False
+
+ def handler_func_2(sender, instance, created, **kwargs):
+ nonlocal handler_called_2
+ handler_called_2 = True
+
+ decorated_handler = after_create_commit(sender=TodoItem)( # noqa: F841
+ handler_func_1
+ )
+ decorated_handler_2 = after_create_commit(sender=TodoItem)( # noqa: F841
+ handler_func_2
+ )
+
+ TodoItem.objects.create(description="Test Model")
+
+ assert handler_called_1
+ assert handler_called_2
+
+ def test_after_update_commit_signal_handler(self):
+ handler_called_1 = False
+
+ def handler_func_1(sender, instance, created, **kwargs):
+ nonlocal handler_called_1
+ handler_called_1 = True
+
+ handler_called_2 = False
+
+ def handler_func_2(sender, instance, created, **kwargs):
+ nonlocal handler_called_2
+ handler_called_2 = True
+
+ decorated_handler = after_update_commit(sender=TodoItem)( # noqa: F841
+ handler_func_1
+ )
+ decorated_handler_2 = after_update_commit(sender=TodoItem)( # noqa: F841
+ handler_func_2
+ )
+
+ todo_item = TodoItem.objects.create(description="Test Model")
+ todo_item.description = "test"
+ todo_item.save()
+
+ assert handler_called_1
+ assert handler_called_2
+
+ def test_after_delete_commit_signal_handler(self):
+ handler_called_1 = False
+
+ def handler_func_1(sender, instance, **kwargs):
+ nonlocal handler_called_1
+ handler_called_1 = True
+
+ handler_called_2 = False
+
+ def handler_func_2(sender, instance, **kwargs):
+ nonlocal handler_called_2
+ handler_called_2 = True
+
+ decorated_handler = after_delete_commit(sender=TodoItem)( # noqa: F841
+ handler_func_1
+ )
+ decorated_handler_2 = after_delete_commit(sender=TodoItem)( # noqa: F841
+ handler_func_2
+ )
+
+ todo_item = TodoItem.objects.create(description="Test Model")
+ todo_item.delete()
+
+ assert handler_called_1
+ assert handler_called_2
diff --git a/tests/test_turbo_power.py b/tests/test_turbo_power.py
index 62e5b58..6eec0ac 100644
--- a/tests/test_turbo_power.py
+++ b/tests/test_turbo_power.py
@@ -1,17 +1,11 @@
import pytest
-from bs4 import BeautifulSoup
+from tests.utils import assert_dom_equal
from turbo_helper import turbo_stream
pytestmark = pytest.mark.django_db
-def assert_dom_equal(expected_html, actual_html):
- expected_soup = BeautifulSoup(expected_html, "html.parser")
- actual_soup = BeautifulSoup(actual_html, "html.parser")
- assert str(expected_soup) == str(actual_soup)
-
-
class TestGraft:
def test_graft(self):
stream = ''
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..9c62038
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,11 @@
+from bs4 import BeautifulSoup
+
+
+def assert_dom_equal(expected_html, actual_html):
+ expected_soup = BeautifulSoup(expected_html, "html.parser")
+ actual_soup = BeautifulSoup(actual_html, "html.parser")
+
+ expected_str = expected_soup.prettify()
+ actual_str = actual_soup.prettify()
+
+ assert expected_str == actual_str