diff --git a/src/turbo_helper/__init__.py b/src/turbo_helper/__init__.py
index 23f475b..5ef1a7a 100644
--- a/src/turbo_helper/__init__.py
+++ b/src/turbo_helper/__init__.py
@@ -1,10 +1,12 @@
-from .constants import TURBO_STREAM_MIME_TYPE as TURBO_STREAM_CONTENT_TYPE
from .constants import ResponseFormat
from .response import HttpResponseSeeOther, TurboStreamResponse
from .shortcuts import redirect_303, response_format
from .stream import register_turbo_stream_action, turbo_stream
from .templatetags.turbo_helper import dom_id
+# extend turbo_stream actions, inspired by https://github.com/marcoroth/turbo_power
+from .turbo_power import * # noqa
+
__all__ = [
"turbo_stream",
"register_turbo_stream_action",
@@ -14,5 +16,4 @@
"dom_id",
"response_format",
"ResponseFormat",
- "TURBO_STREAM_CONTENT_TYPE",
]
diff --git a/src/turbo_helper/renderers.py b/src/turbo_helper/renderers.py
index 7a1af38..63aa022 100644
--- a/src/turbo_helper/renderers.py
+++ b/src/turbo_helper/renderers.py
@@ -22,13 +22,15 @@ def render_turbo_stream(
element_attributes_array = []
for key, value in element_attributes.items():
+ if value is None:
+ continue
# TODO: bool type django/forms/widgets/attrs.html
element_attributes_array.append(f'{key}="{escape(value)}"')
attribute_string = mark_safe(" ".join(element_attributes_array))
django_engine = engines["django"]
- template_string = """{{ content|default:'' }}"""
+ template_string = """{{ content|default:'' }}"""
context = {
"content": content,
"action": action,
@@ -43,6 +45,7 @@ def render_turbo_frame(frame_id: str, content: str, attributes: Dict[str, Any])
# convert data_xxx to data-xxx
element_attributes = {}
for key, value in attributes.items():
+ # convert data_xxx to data-xxx
if key.startswith("data"):
element_attributes[key.replace("_", "-")] = value
else:
@@ -50,6 +53,8 @@ def render_turbo_frame(frame_id: str, content: str, attributes: Dict[str, Any])
element_attributes_array = []
for key, value in element_attributes.items():
+ if value is None:
+ continue
# TODO: bool type django/forms/widgets/attrs.html
element_attributes_array.append(f'{key}="{escape(value)}"')
diff --git a/src/turbo_helper/templatetags/turbo_helper.py b/src/turbo_helper/templatetags/turbo_helper.py
index f3df06e..71eae16 100644
--- a/src/turbo_helper/templatetags/turbo_helper.py
+++ b/src/turbo_helper/templatetags/turbo_helper.py
@@ -116,12 +116,24 @@ def render(self, context):
)
elif targets:
action = self.action.resolve(context)
- func = getattr(turbo_stream, f"{action}_all")
- return func(
- targets=targets,
- content=children,
- **attributes,
- )
+ func = getattr(turbo_stream, f"{action}_all", None)
+
+ if func:
+ return func(
+ targets=targets,
+ content=children,
+ **attributes,
+ )
+ else:
+ # fallback to pass targets to the single target handler
+ # we do this because of turbo_power
+ action = self.action.resolve(context)
+ func = getattr(turbo_stream, f"{action}")
+ return func(
+ targets=targets,
+ content=children,
+ **attributes,
+ )
class TurboStreamFromTagNode(Node):
diff --git a/src/turbo_helper/turbo_power.py b/src/turbo_helper/turbo_power.py
new file mode 100644
index 0000000..bf3434c
--- /dev/null
+++ b/src/turbo_helper/turbo_power.py
@@ -0,0 +1,153 @@
+"""
+https://github.com/marcoroth/turbo_power-rails
+
+Bring turbo_power to Django
+"""
+import json
+
+from django.utils.safestring import mark_safe
+
+from turbo_helper import register_turbo_stream_action, turbo_stream
+
+
+def transform_attributes(attributes):
+ transformed_attributes = {}
+ for key, value in attributes.items():
+ transformed_key = transform_key(key)
+ transformed_value = transform_value(value)
+ transformed_attributes[transformed_key] = transformed_value
+ return transformed_attributes
+
+
+def transform_key(key):
+ return str(key).replace("_", "-")
+
+
+def transform_value(value):
+ if isinstance(value, str):
+ return value
+ elif isinstance(value, (int, float, bool)):
+ return str(value).lower()
+ elif value is None:
+ return None
+ else:
+ return json.dumps(value)
+
+
+################################################################################
+
+
+def custom_action(action, target=None, content=None, **kwargs):
+ return turbo_stream.action(
+ action, target=target, content=content, **transform_attributes(kwargs)
+ )
+
+
+def custom_action_all(action, targets=None, content=None, **kwargs):
+ return turbo_stream.action_all(
+ action, targets=targets, content=content, **transform_attributes(kwargs)
+ )
+
+
+# DOM Actions
+
+
+@register_turbo_stream_action("graft")
+def graft(targets=None, parent=None, **attributes):
+ return custom_action_all(
+ "graft",
+ targets=targets,
+ parent=parent,
+ **attributes,
+ )
+
+
+@register_turbo_stream_action("morph")
+def morph(targets=None, html=None, **attributes):
+ html = html or attributes.pop("content", None)
+ return custom_action_all(
+ "morph",
+ targets=targets,
+ content=mark_safe(html) if html else None,
+ **attributes,
+ )
+
+
+# Attribute Actions
+
+
+@register_turbo_stream_action("add_css_class")
+def add_css_class(targets=None, classes="", **attributes):
+ classes = attributes.get("classes", classes)
+ if isinstance(classes, list):
+ classes = " ".join(classes)
+
+ return custom_action_all(
+ "add_css_class",
+ targets=targets,
+ classes=classes,
+ **attributes,
+ )
+
+
+# Event Actions
+
+
+@register_turbo_stream_action("dispatch_event")
+def dispatch_event(targets=None, name=None, detail=None, **attributes):
+ detail = detail or {}
+ return custom_action_all(
+ "dispatch_event",
+ targets=targets,
+ name=name,
+ content=mark_safe(json.dumps(detail, separators=(",", ":"))),
+ **attributes,
+ )
+
+
+# Notification Actions
+
+
+@register_turbo_stream_action("notification")
+def notification(title=None, **attributes):
+ return custom_action(
+ "notification",
+ title=title,
+ **attributes,
+ )
+
+
+# Turbo Actions
+
+
+@register_turbo_stream_action("redirect_to")
+def redirect_to(url=None, turbo_action="advance", turbo_frame=None, **attributes):
+ return custom_action(
+ "redirect_to",
+ url=url,
+ turbo_action=turbo_action,
+ turbo_frame=turbo_frame,
+ **attributes,
+ )
+
+
+# Turbo Frame Actions
+
+
+@register_turbo_stream_action("turbo_frame_reload")
+def turbo_frame_reload(target=None, **attributes):
+ return custom_action(
+ "turbo_frame_reload",
+ target=target,
+ **attributes,
+ )
+
+
+@register_turbo_stream_action("turbo_frame_set_src")
+def turbo_frame_set_src(target=None, src=None, **attributes):
+ return custom_action(
+ "turbo_frame_set_src",
+ target=target,
+ src=src,
+ **attributes,
+ )
diff --git a/tests/test_turbo_power.py b/tests/test_turbo_power.py
new file mode 100644
index 0000000..62e5b58
--- /dev/null
+++ b/tests/test_turbo_power.py
@@ -0,0 +1,371 @@
+import pytest
+from bs4 import BeautifulSoup
+
+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 = ''
+ assert_dom_equal(stream, turbo_stream.graft("#input", "#parent"))
+
+ def test_graft_with_targets_and_html_as_kwargs(self):
+ stream = ''
+ assert_dom_equal(stream, turbo_stream.graft(targets="#input", parent="#parent"))
+
+ stream = ''
+ assert_dom_equal(stream, turbo_stream.graft(parent="#parent", targets="#input"))
+
+ def test_graft_with_targets_as_positional_arg_and_html_as_kwarg(self):
+ stream = ''
+ assert_dom_equal(stream, turbo_stream.graft("#input", parent="#parent"))
+
+ def test_graft_with_additional_arguments(self):
+ stream = ''
+ assert_dom_equal(
+ stream, turbo_stream.graft("#input", parent="#parent", something="else")
+ )
+
+
+class TestMorph:
+ def test_morph(self):
+ stream = 'Morph
'
+ assert_dom_equal(stream, turbo_stream.morph("#input", "
Morph
"))
+
+ def test_morph_with_targets_and_html_as_kwargs(self):
+ stream = 'Morph
'
+ assert_dom_equal(
+ stream, turbo_stream.morph(targets="#input", html="Morph
")
+ )
+
+ def test_morph_with_target_and_html_as_kwargs(self):
+ stream = 'Morph
'
+ assert_dom_equal(
+ stream, turbo_stream.morph(target="input", html="Morph
")
+ )
+
+ def test_morph_with_html_and_targets_as_kwargs(self):
+ stream = 'Morph
'
+ assert_dom_equal(
+ stream, turbo_stream.morph(html="Morph
", targets="#input")
+ )
+
+ def test_morph_with_targets_as_positional_arg_and_html_as_kwarg(self):
+ stream = 'Morph
'
+ assert_dom_equal(stream, turbo_stream.morph("#input", html="Morph
"))
+
+ def test_morph_with_additional_arguments(self):
+ stream = 'Morph
'
+ assert_dom_equal(
+ stream, turbo_stream.morph("#input", html="Morph
", something="else")
+ )
+
+
+class TestAddCssClass:
+ def test_add_css_class(self):
+ stream = ''
+ assert_dom_equal(
+ stream, turbo_stream.add_css_class("#element", "container text-center")
+ )
+
+ def test_add_css_class_with_targets_and_classes_as_kwargs(self):
+ stream = ''
+ assert_dom_equal(
+ stream,
+ turbo_stream.add_css_class(
+ targets="#element", classes="container text-center"
+ ),
+ )
+
+ def test_add_css_class_with_classes_and_targets_as_kwargs(self):
+ stream = ''
+ assert_dom_equal(
+ stream,
+ turbo_stream.add_css_class(
+ classes="container text-center", targets="#element"
+ ),
+ )
+
+ def test_add_css_class_with_targets_as_positional_arg_and_classes_as_kwarg(self):
+ stream = ''
+ assert_dom_equal(
+ stream,
+ turbo_stream.add_css_class("#element", classes="container text-center"),
+ )
+
+ def test_add_css_class_with_additional_arguments(self):
+ stream = ''
+ assert_dom_equal(
+ stream,
+ turbo_stream.add_css_class(
+ "#element", classes="container text-center", something="else"
+ ),
+ )
+
+ def test_add_css_class_with_classes_as_array(self):
+ stream = ''
+ assert_dom_equal(
+ stream, turbo_stream.add_css_class("#element", ["container", "text-center"])
+ )
+
+ def test_add_css_class_with_classes_as_array_and_kwarg(self):
+ stream = ''
+ assert_dom_equal(
+ stream,
+ turbo_stream.add_css_class(
+ "#element", classes=["container", "text-center"]
+ ),
+ )
+
+
+class TestDispatchEvent:
+ def test_dispatch_event(self):
+ stream = '{}'
+ assert_dom_equal(
+ stream, turbo_stream.dispatch_event("#element", "custom-event")
+ )
+
+ def test_dispatch_event_with_detail(self):
+ stream = '{"count":1,"type":"custom","enabled":true,"ids":[1,2,3]}'
+ assert_dom_equal(
+ stream,
+ turbo_stream.dispatch_event(
+ "#element",
+ "custom-event",
+ detail={
+ "count": 1,
+ "type": "custom",
+ "enabled": True,
+ "ids": [1, 2, 3],
+ },
+ ),
+ )
+
+ def test_dispatch_event_with_name_as_kwarg(self):
+ stream = '{}'
+ assert_dom_equal(
+ stream, turbo_stream.dispatch_event("#element", name="custom-event")
+ )
+
+ def test_dispatch_event_with_targets_and_name_as_kwarg(self):
+ stream = '{}'
+ assert_dom_equal(
+ stream, turbo_stream.dispatch_event(targets="#element", name="custom-event")
+ )
+
+ def test_dispatch_event_with_target_and_name_as_kwarg(self):
+ stream = '{}'
+ assert_dom_equal(
+ stream, turbo_stream.dispatch_event(target="element", name="custom-event")
+ )
+
+ def test_dispatch_event_with_targets_name_and_detail_as_kwargs(self):
+ stream = '{"count":1,"type":"custom","enabled":true,"ids":[1,2,3]}'
+ assert_dom_equal(
+ stream,
+ turbo_stream.dispatch_event(
+ targets="#element",
+ name="custom-event",
+ detail={
+ "count": 1,
+ "type": "custom",
+ "enabled": True,
+ "ids": [1, 2, 3],
+ },
+ ),
+ )
+
+ def test_dispatch_event_with_additional_attributes(self):
+ stream = '{}'
+ assert_dom_equal(
+ stream,
+ turbo_stream.dispatch_event(
+ "#element", name="custom-event", something="else"
+ ),
+ )
+
+
+class TestNotification:
+ def test_notification_with_just_title(self):
+ stream = ''
+ assert_dom_equal(stream, turbo_stream.notification("A title"))
+
+ def test_notification_with_title_and_option(self):
+ stream = ''
+ assert_dom_equal(stream, turbo_stream.notification("A title", body="A body"))
+
+ def test_notification_with_title_and_all_options(self):
+ stream = """
+
+ """.strip()
+
+ options = {
+ "dir": "ltr",
+ "lang": "EN",
+ "badge": "https://example.com/badge.png",
+ "body": "This is displayed below the title.",
+ "tag": "Demo",
+ "icon": "https://example.com/icon.png",
+ "image": "https://example.com/image.png",
+ "data": '{"arbitrary":"data"}',
+ "vibrate": "[200,100,200]",
+ "renotify": "true",
+ "require-interaction": "true",
+ "actions": '[{"action":"respond","title":"Please respond","icon":"https://example.com/icon.png"}]',
+ "silent": "true",
+ }
+
+ assert_dom_equal(stream, turbo_stream.notification("A title", **options))
+
+ def test_notification_with_title_kwarg(self):
+ stream = ''
+ assert_dom_equal(stream, turbo_stream.notification(title="A title"))
+
+
+class TestRedirectTo:
+ def test_redirect_to_default(self):
+ stream = ''
+ assert_dom_equal(stream, turbo_stream.redirect_to("http://localhost:8080"))
+
+ def test_redirect_to_with_turbo_false(self):
+ stream = ''
+ assert_dom_equal(
+ stream, turbo_stream.redirect_to("http://localhost:8080", turbo=False)
+ )
+
+ def test_redirect_to_with_turbo_action_replace(self):
+ stream = ''
+ assert_dom_equal(
+ stream, turbo_stream.redirect_to("http://localhost:8080", "replace")
+ )
+
+ def test_redirect_to_with_turbo_action_replace_kwarg(self):
+ stream = ''
+ assert_dom_equal(
+ stream,
+ turbo_stream.redirect_to("http://localhost:8080", turbo_action="replace"),
+ )
+
+ def test_redirect_to_with_turbo_action_replace_and_turbo_frame_modals_as_positional_arguments(
+ self,
+ ):
+ stream = ''
+ assert_dom_equal(
+ stream,
+ turbo_stream.redirect_to("http://localhost:8080", "replace", "modals"),
+ )
+
+ def test_redirect_to_with_turbo_action_replace_as_positional_argument_and_turbo_frame_modals_as_kwarg(
+ self,
+ ):
+ stream = ''
+ assert_dom_equal(
+ stream,
+ turbo_stream.redirect_to(
+ "http://localhost:8080", "replace", turbo_frame="modals"
+ ),
+ )
+
+ def test_redirect_to_with_turbo_action_replace_and_turbo_frame_modals_as_kwargs(
+ self,
+ ):
+ stream = ''
+ assert_dom_equal(
+ stream,
+ turbo_stream.redirect_to(
+ "http://localhost:8080", turbo_action="replace", turbo_frame="modals"
+ ),
+ )
+
+ def test_redirect_to_all_kwargs(self):
+ stream = ''
+ assert_dom_equal(
+ stream,
+ turbo_stream.redirect_to(
+ url="http://localhost:8080",
+ turbo_action="replace",
+ turbo_frame="modals",
+ turbo=True,
+ ),
+ )
+
+
+class TestTurboFrameReload:
+ def test_turbo_frame_reload(self):
+ stream = ''
+ assert_dom_equal(stream, turbo_stream.turbo_frame_reload("user_1"))
+
+ def test_turbo_frame_reload_with_target_kwarg(self):
+ stream = ''
+ assert_dom_equal(stream, turbo_stream.turbo_frame_reload(target="user_1"))
+
+ def test_turbo_frame_reload_with_targets_kwarg(self):
+ stream = ''
+ assert_dom_equal(stream, turbo_stream.turbo_frame_reload(targets="#user_1"))
+
+ def test_turbo_frame_reload_additional_attribute(self):
+ stream = ''
+ assert_dom_equal(
+ stream, turbo_stream.turbo_frame_reload("user_1", something="else")
+ )
+
+
+class TestTurboFrameSetSrc:
+ def test_turbo_frame_set_src(self):
+ stream = ''
+ assert_dom_equal(stream, turbo_stream.turbo_frame_set_src("user_1", "/users"))
+
+ def test_turbo_frame_set_src_with_src_kwarg(self):
+ stream = ''
+ assert_dom_equal(
+ stream, turbo_stream.turbo_frame_set_src("user_1", src="/users")
+ )
+
+ def test_turbo_frame_set_src_with_target_and_src_kwarg(self):
+ stream = ''
+ assert_dom_equal(
+ stream, turbo_stream.turbo_frame_set_src(target="user_1", src="/users")
+ )
+
+ def test_turbo_frame_set_src_with_src_and_target_kwarg(self):
+ stream = ''
+ assert_dom_equal(
+ stream, turbo_stream.turbo_frame_set_src(src="/users", target="user_1")
+ )
+
+ def test_turbo_frame_set_src_with_targets_and_src_kwarg(self):
+ stream = ''
+ assert_dom_equal(
+ stream, turbo_stream.turbo_frame_set_src(targets="#user_1", src="/users")
+ )
+
+ def test_turbo_frame_set_src_additional_attribute(self):
+ stream = ''
+ assert_dom_equal(
+ stream,
+ turbo_stream.turbo_frame_set_src("user_1", src="/users", something="else"),
+ )
diff --git a/tox.ini b/tox.ini
index 1b6e981..490b78c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -19,6 +19,7 @@ deps =
pytest-xdist
pytest-mock
jinja2
+ BeautifulSoup4
usedevelop = True
commands =
pytest {posargs}