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 b092bca2..7dd9e3a7 100644 --- a/django_unicorn/components/unicorn_view.py +++ b/django_unicorn/components/unicorn_view.py @@ -10,6 +10,7 @@ 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 @@ -299,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, @@ -341,8 +349,13 @@ 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, + context=self.get_context_data(), + component=self, + init_js=True, ) @timed 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/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