Skip to content

Commit

Permalink
Initial updates to get direct view working.
Browse files Browse the repository at this point in the history
  • Loading branch information
adamghill committed Dec 18, 2021
1 parent d77d48d commit 5324e3d
Show file tree
Hide file tree
Showing 11 changed files with 179 additions and 51 deletions.
18 changes: 15 additions & 3 deletions django_unicorn/components/unicorn_template_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")
49 changes: 48 additions & 1 deletion django_unicorn/components/unicorn_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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,
Expand All @@ -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:
"""
Expand Down Expand Up @@ -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)
8 changes: 8 additions & 0 deletions django_unicorn/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,11 @@ class ComponentLoadError(Exception):

class RenderNotModified(Exception):
pass


class MissingComponentElement(Exception):
pass


class MissingComponentViewElement(Exception):
pass
3 changes: 2 additions & 1 deletion django_unicorn/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand Down
7 changes: 7 additions & 0 deletions example/unicorn/components/direct_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.utils.timezone import now

from django_unicorn.components import UnicornView


class DirectViewView(UnicornView):
name = "test"
13 changes: 13 additions & 0 deletions example/unicorn/templates/unicorn/direct-view.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% extends "www/base.html" %}
{% load unicorn %}

{% block content %}

<div unicorn:view>
<h2>Direct View</h2>

<input u:model="name" />
{{ name }}
</div>

{% endblock content %}
90 changes: 47 additions & 43 deletions example/www/templates/www/base.html
Original file line number Diff line number Diff line change
@@ -1,48 +1,52 @@
{% load static unicorn %}

<html>
<head>
<title>django-unicorn examples</title>
<link rel="stylesheet" href="{% static 'css/marx.min.css' %}">
<link rel="stylesheet" href="{% static 'css/styles.css' %}">
<style>
#menu {
list-style: none;
}
#menu li {
display: inline;
padding: 0 10px;
border-left: solid 1px black;
}
#menu li:first-child {
border-left: none !important;
}
</style>

{% unicorn_scripts %}
</head>

<body>
<main>
{% csrf_token %}

<h1>django-unicorn</h1>

<ul id="menu">
<li><a href="{% url 'www:index' %}">Index</a></li>
<li><a href="{% url 'www:template' 'text-inputs' %}">Text inputs</a></li>
<li><a href="{% url 'www:template' 'html-inputs' %}">Other inputs</a></li>
<li><a href="{% url 'www:template' 'objects' %}">Objects</a></li>
<li><a href="{% url 'www:template' 'validation' %}">Validation</a></li>
<li><a href="{% url 'www:template' 'polling' %}">Polling</a></li>
<li><a href="{% url 'www:template' 'models' %}">Models</a></li>
<li><a href="{% url 'www:template' 'nested' %}">Nested components (table)</a></li>
<li><a href="{% url 'www:template' 'js' %}">JavaScript integration</a></li>
</ul>

{% block content %}{% endblock content %}

</main>
</body>

<head>
<title>django-unicorn examples</title>
<link rel="stylesheet" href="{% static 'css/marx.min.css' %}">
<link rel="stylesheet" href="{% static 'css/styles.css' %}">
<style>
#menu {
list-style: none;
}

#menu li {
display: inline;
padding: 0 10px;
border-left: solid 1px black;
}

#menu li:first-child {
border-left: none !important;
}
</style>

{% unicorn_scripts %}
</head>

<body>
<main>
{% csrf_token %}

<h1>django-unicorn</h1>

<ul id="menu">
<li><a href="{% url 'www:index' %}">Index</a></li>
<li><a href="{% url 'www:template' 'text-inputs' %}">Text inputs</a></li>
<li><a href="{% url 'www:template' 'html-inputs' %}">Other inputs</a></li>
<li><a href="{% url 'www:template' 'objects' %}">Objects</a></li>
<li><a href="{% url 'www:template' 'validation' %}">Validation</a></li>
<li><a href="{% url 'www:template' 'polling' %}">Polling</a></li>
<li><a href="{% url 'www:template' 'models' %}">Models</a></li>
<li><a href="{% url 'www:template' 'nested' %}">Nested components (table)</a></li>
<li><a href="{% url 'www:template' 'js' %}">JavaScript integration</a></li>
<li><a href="{% url 'www:direct-view' %}">Direct View</a></li>
</ul>

{% block content %}{% endblock content %}

</main>
</body>

</html>
7 changes: 7 additions & 0 deletions example/www/urls.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
from django.urls import path

from example.unicorn.components.direct_view import DirectViewView

from . import views


app_name = "www"

urlpatterns = [
path("", views.index, name="index"),
path(
"direct-view",
DirectViewView.as_view(),
name="direct-view",
),
path("<str:name>", views.template, name="template"),
]
8 changes: 8 additions & 0 deletions tests/components/test_convert_to_dash_case.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 23 additions & 1 deletion tests/components/test_unicorn_template_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
UnicornTemplateResponse,
get_root_element,
)
from django_unicorn.errors import MissingComponentElement, MissingComponentViewElement


def test_get_root_element():
Expand Down Expand Up @@ -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 = '<div unicorn:view="">test</div>'

component_html = (
"<html><head></head>≤body><div unicorn:view>test</div></body></html>"
)
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 = "<html><head></head>≤body><div>test</div></body></html>"
soup = BeautifulSoup(component_html, features="html.parser")

with pytest.raises(MissingComponentViewElement):
get_root_element(soup)


def test_desoupify():
html = "<div>&lt;a&gt;&lt;style&gt;@keyframes x{}&lt;/style&gt;&lt;a style=&quot;animation-name:x&quot; onanimationend=&quot;alert(1)&quot;&gt;&lt;/a&gt;!\n</div>\n\n<script type=\"application/javascript\">\n window.addEventListener('DOMContentLoaded', (event) => {\n Unicorn.addEventListener('updated', (component) => console.log('got updated', component));\n });\n</script>"
expected = "<div>&lt;a&gt;&lt;style&gt;@keyframes x{}&lt;/style&gt;&lt;a style=\"animation-name:x\" onanimationend=\"alert(1)\"&gt;&lt;/a&gt;!\n</div>\n<script type=\"application/javascript\">\n window.addEventListener('DOMContentLoaded', (event) => {\n Unicorn.addEventListener('updated', (component) => console.log('got updated', component));\n });\n</script>"
Expand Down
3 changes: 1 addition & 2 deletions tests/templatetags/test_unicorn_render.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down

0 comments on commit 5324e3d

Please sign in to comment.