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 = """""" + template_string = """""" 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 = '' + assert_dom_equal(stream, turbo_stream.morph("#input", "

Morph

")) + + def test_morph_with_targets_and_html_as_kwargs(self): + stream = '' + assert_dom_equal( + stream, turbo_stream.morph(targets="#input", html="

Morph

") + ) + + def test_morph_with_target_and_html_as_kwargs(self): + stream = '' + assert_dom_equal( + stream, turbo_stream.morph(target="input", html="

Morph

") + ) + + def test_morph_with_html_and_targets_as_kwargs(self): + stream = '' + 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 = '' + assert_dom_equal(stream, turbo_stream.morph("#input", html="

Morph

")) + + def test_morph_with_additional_arguments(self): + stream = '' + 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 = '' + 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 = '' + 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}