Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Direct view #181

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
2 changes: 2 additions & 0 deletions example/project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")


DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

UNICORN = {
"SERIAL": {
"ENABLED": True,
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"),
]
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "django-unicorn"
version = "0.39.0"
version = "0.39.1"
description = "A magical full-stack framework for Django."
authors = ["Adam Hill <unicorn@adamghill.com>"]
license = "MIT"
Expand Down
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