diff --git a/django_unicorn/components/unicorn_template_response.py b/django_unicorn/components/unicorn_template_response.py index a6363c9f..d9bd2f63 100644 --- a/django_unicorn/components/unicorn_template_response.py +++ b/django_unicorn/components/unicorn_template_response.py @@ -11,6 +11,7 @@ from django_unicorn.utils import sanitize_html from ..decorators import timed +from ..errors import MissingComponentElement, MissingComponentViewElement from ..utils import generate_checksum @@ -132,15 +133,26 @@ def _desoupify(soup): def get_root_element(soup: BeautifulSoup) -> Tag: """ - Gets the first tag element. + Gets the first tag element for the component or the first element with a `unicorn:view` attribute for a direct view. Returns: BeautifulSoup tag element. - Raises an Exception if an element cannot be found. + Raises `Exception` if an element cannot be found. """ + for element in soup.contents: if isinstance(element, Tag) and element.name: + if element.name == "html": + view_element = element.find_next(attrs={"unicorn:view": True}) + + if not view_element: + raise MissingComponentViewElement( + "An element with an `unicorn:view` attribute is required for a direct view" + ) + + return view_element + return element - raise Exception("No root element found") + raise MissingComponentElement("No root element for the component was found") diff --git a/django_unicorn/components/unicorn_view.py b/django_unicorn/components/unicorn_view.py index 150fac4a..7dd9e3a7 100644 --- a/django_unicorn/components/unicorn_view.py +++ b/django_unicorn/components/unicorn_view.py @@ -10,8 +10,10 @@ from django.core.exceptions import ImproperlyConfigured from django.db.models import Model from django.http import HttpRequest +from django.utils.decorators import classonlymethod from django.views.generic.base import TemplateView +import shortuuid from cachetools.lru import LRUCache from django_unicorn.settings import get_cache_alias @@ -45,6 +47,11 @@ def convert_to_snake_case(s: str) -> str: return s.replace("-", "_") +def convert_to_dash_case(s: str) -> str: + # TODO: Better handling of snake->dash + return s.replace("_", "-") + + def convert_to_pascal_case(s: str) -> str: # TODO: Better handling of dash/snake->pascal-case s = convert_to_snake_case(s) @@ -132,10 +139,14 @@ def construct_component( return component +from django.utils.decorators import classonlymethod + + class UnicornView(TemplateView): response_class = UnicornTemplateResponse component_name: str = "" component_key: str = "" + component_id: str = "" request = None parent = None children = [] @@ -289,18 +300,25 @@ def called(self, name, args): pass @timed - def render(self, init_js=False, extra_context=None) -> str: + def render(self, init_js=False, extra_context=None, request=None) -> str: """ Renders a UnicornView component with the public properties available. Delegates to a UnicornTemplateResponse to actually render a response. Args: param init_js: Whether or not to include the Javascript required to initialize the component. + param extra_context: + param request: Set the `request` for rendering. Usually it will be in the context, + but it is missing when the component is re-rendered as a direct view, so it needs + to be set explicitly. """ if extra_context is not None: self.extra_context = extra_context + if request: + self.request = request + response = self.render_to_response( context=self.get_context_data(), component=self, @@ -326,6 +344,20 @@ def render(self, init_js=False, extra_context=None) -> str: return rendered_component + def dispatch(self, request, *args, **kwargs): + """ + Called by the `as_view` class method when utilizing a component directly as a view. + """ + + self.mount() + self.hydrate() + + return self.render_to_response( + context=self.get_context_data(), + component=self, + init_js=True, + ) + @timed def get_frontend_context_variables(self) -> str: """ @@ -799,3 +831,18 @@ def _get_component_class( raise ComponentLoadError( f"'{component_name}' component could not be loaded: {last_exception}" ) from last_exception + + @classonlymethod + def as_view(cls, **initkwargs): + if "component_id" not in initkwargs: + initkwargs["component_id"] = shortuuid.uuid()[:8] + + if "component_name" not in initkwargs: + module_name = cls.__module__ + module_parts = module_name.split(".") + component_name = module_parts[len(module_parts) - 1] + component_name = convert_to_dash_case(component_name) + + initkwargs["component_name"] = component_name + + return super().as_view(**initkwargs) diff --git a/django_unicorn/errors.py b/django_unicorn/errors.py index d15ba0c6..377542c4 100644 --- a/django_unicorn/errors.py +++ b/django_unicorn/errors.py @@ -12,3 +12,11 @@ class ComponentLoadError(Exception): class RenderNotModified(Exception): pass + + +class MissingComponentElement(Exception): + pass + + +class MissingComponentViewElement(Exception): + pass diff --git a/django_unicorn/views/__init__.py b/django_unicorn/views/__init__.py index 8839c83f..5a14c2c9 100644 --- a/django_unicorn/views/__init__.py +++ b/django_unicorn/views/__init__.py @@ -157,7 +157,8 @@ def _process_component_request( else: component.validate(model_names=list(updated_data.keys())) - rendered_component = component.render() + # Pass the current request so that it can be used inside the component template + rendered_component = component.render(request=request) component.rendered(rendered_component) cache = caches[get_cache_alias()] diff --git a/example/www/urls.py b/example/www/urls.py index a4e6f519..14600ad1 100644 --- a/example/www/urls.py +++ b/example/www/urls.py @@ -1,5 +1,7 @@ from django.urls import path +from example.unicorn.components.text_inputs import TextInputsView + from . import views @@ -7,5 +9,6 @@ urlpatterns = [ path("", views.index, name="index"), + path("direct-view", TextInputsView.as_view(), name="direct-view",), path("", views.template, name="template"), ] diff --git a/tests/components/test_convert_to_dash_case.py b/tests/components/test_convert_to_dash_case.py new file mode 100644 index 00000000..6d65a7e3 --- /dev/null +++ b/tests/components/test_convert_to_dash_case.py @@ -0,0 +1,8 @@ +from django_unicorn.components.unicorn_view import convert_to_dash_case + + +def test_convert_to_dash_case(): + expected = "hello-world" + actual = convert_to_dash_case("hello_world") + + assert expected == actual diff --git a/tests/components/test_unicorn_template_response.py b/tests/components/test_unicorn_template_response.py index e0f63332..acc64417 100644 --- a/tests/components/test_unicorn_template_response.py +++ b/tests/components/test_unicorn_template_response.py @@ -5,6 +5,7 @@ UnicornTemplateResponse, get_root_element, ) +from django_unicorn.errors import MissingComponentElement, MissingComponentViewElement def test_get_root_element(): @@ -43,12 +44,33 @@ def test_get_root_element_no_element(): component_html = "\n" soup = BeautifulSoup(component_html, features="html.parser") - with pytest.raises(Exception): + with pytest.raises(MissingComponentElement): actual = get_root_element(soup) assert str(actual) == expected +def test_get_root_element_direct_view(): + # Annoyingly beautifulsoup adds a blank string on the attribute + expected = '
test
' + + component_html = ( + " ≤body>
test
" + ) + soup = BeautifulSoup(component_html, features="html.parser") + actual = get_root_element(soup) + + assert str(actual) == expected + + +def test_get_root_element_direct_view_no_view(): + component_html = " ≤body>
test
" + soup = BeautifulSoup(component_html, features="html.parser") + + with pytest.raises(MissingComponentViewElement): + get_root_element(soup) + + def test_desoupify(): html = "
<a><style>@keyframes x{}</style><a style="animation-name:x" onanimationend="alert(1)"></a>!\n
\n\n" expected = "
<a><style>@keyframes x{}</style><a style=\"animation-name:x\" onanimationend=\"alert(1)\"></a>!\n
\n" diff --git a/tests/templatetags/test_unicorn_render.py b/tests/templatetags/test_unicorn_render.py index 1ae99967..99a36c08 100644 --- a/tests/templatetags/test_unicorn_render.py +++ b/tests/templatetags/test_unicorn_render.py @@ -1,7 +1,6 @@ import re -from django.http.request import HttpRequest -from django.template import Context, RequestContext +from django.template import Context from django.template.base import Token, TokenType import pytest