mirror of
https://github.com/django-components/django-components.git
synced 2025-08-04 14:28:18 +00:00
Render components as views (#366) (thanks @dylanjcastillo)
This commit is contained in:
parent
b29e7fba80
commit
91b4accfeb
10 changed files with 347 additions and 15 deletions
68
README.md
68
README.md
|
@ -20,6 +20,8 @@ Read on to learn about the details!
|
|||
|
||||
## Release notes
|
||||
|
||||
*Version 0.34* adds components as views, which allows you to handle requests and render responses from within a component. See the [documentation](#components-as-views) for more details.
|
||||
|
||||
*Version 0.28* introduces 'implicit' slot filling and the `default` option for `slot` tags.
|
||||
|
||||
*Version 0.27* adds a second installable app: *django_components.safer_staticfiles*. It provides the same behavior as *django.contrib.staticfiles* but with extra security guarantees (more info below in Security Notes).
|
||||
|
@ -403,6 +405,72 @@ This is fine too:
|
|||
{% endcomponent_block %}
|
||||
```
|
||||
|
||||
### Components as views
|
||||
|
||||
_New in version 0.34_
|
||||
|
||||
Components can now be used as views. To do this, `Component` subclasses Django's `View` class. This means that you can use all of the [methods](https://docs.djangoproject.com/en/5.0/ref/class-based-views/base/#view) of `View` in your component. For example, you can override `get` and `post` to handle GET and POST requests, respectively.
|
||||
|
||||
In addition, `Component` now has a `render_to_response` method that renders the component template based on the provided context and slots' data and returns an `HttpResponse` object.
|
||||
|
||||
Here's an example of a calendar component defined as a view:
|
||||
|
||||
```python
|
||||
# In a file called [project root]/components/calendar.py
|
||||
from django_components import component
|
||||
|
||||
@component.register("calendar")
|
||||
class Calendar(component.Component):
|
||||
|
||||
template = """
|
||||
<div class="calendar-component">
|
||||
<div class="header">
|
||||
{% slot "header" %}{% endslot %}
|
||||
</div>
|
||||
<div class="body">
|
||||
Today's date is <span>{{ date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = {
|
||||
"date": request.GET.get("date", "2020-06-06"),
|
||||
}
|
||||
slots = {
|
||||
"header": "Calendar header",
|
||||
}
|
||||
return self.render_to_response(context, slots)
|
||||
```
|
||||
|
||||
Then, to use this component as a view, you should create a `urls.py` file in your components directory, and add a path to the component's view:
|
||||
|
||||
```python
|
||||
# In a file called [project root]/components/urls.py
|
||||
from django.urls import path
|
||||
from calendar import Calendar
|
||||
|
||||
urlpatterns = [
|
||||
path("calendar/", Calendar.as_view()),
|
||||
]
|
||||
```
|
||||
|
||||
Remember to add `__init__.py` to your components directory, so that Django can find the `urls.py` file.
|
||||
|
||||
Finally, include the component's urls in your project's `urls.py` file:
|
||||
|
||||
```python
|
||||
# In a file called [project root]/urls.py
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("components/", include("components.urls")),
|
||||
]
|
||||
```
|
||||
|
||||
Note: slots content are automatically escaped by default to prevent XSS attacks. To disable escaping, set `escape_slots_content=False` in the `render_to_response` method. If you do so, you should make sure that any content you pass to the slots is safe, especially if it comes from user input.
|
||||
|
||||
If you're planning on passing an HTML string, check Django's use of [`format_html`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.html.format_html) and [`mark_safe`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.safestring.mark_safe).
|
||||
|
||||
### Advanced
|
||||
|
||||
|
|
|
@ -4,11 +4,14 @@ from typing import Any, ClassVar, Dict, Iterable, Optional, Set, Tuple, Union
|
|||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.forms.widgets import Media, MediaDefiningClass
|
||||
from django.template.base import NodeList, Template
|
||||
from django.http import HttpResponse
|
||||
from django.template.base import NodeList, Template, TextNode
|
||||
from django.template.context import Context
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.template.loader import get_template
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.views import View
|
||||
|
||||
# Global registry var and register() function moved to separate module.
|
||||
# Defining them here made little sense, since 1) component_tags.py and component.py
|
||||
|
@ -59,7 +62,7 @@ class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
|
|||
return super().__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
|
||||
class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
||||
# Either template_name or template must be set on subclass OR subclass must implement get_template() with
|
||||
# non-null return.
|
||||
template_name: ClassVar[Optional[str]] = None
|
||||
|
@ -84,6 +87,11 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
|
|||
self.outer_context: Context = outer_context or Context()
|
||||
self.fill_content = fill_content
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def class_hash(cls):
|
||||
return hash(str(cls.__module__) + str(cls.__name__))
|
||||
|
||||
def get_context_data(self, *args, **kwargs) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
@ -133,8 +141,18 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
|
|||
f"Note: this attribute is not required if you are overriding the class's `get_template*()` methods."
|
||||
)
|
||||
|
||||
def render(self, context):
|
||||
def render(
|
||||
self,
|
||||
context_data: Dict[str, Any],
|
||||
slots_data: Optional[Dict[SlotName, str]] = None,
|
||||
escape_slots_content: Optional[bool] = True,
|
||||
) -> str:
|
||||
context = Context(context_data)
|
||||
template = self.get_template(context)
|
||||
|
||||
if slots_data:
|
||||
self._fill_slots(slots_data, escape_slots_content)
|
||||
|
||||
updated_filled_slots_context: FilledSlotsContext = (
|
||||
self._process_template_and_update_filled_slot_context(
|
||||
context, template
|
||||
|
@ -145,8 +163,39 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
|
|||
):
|
||||
return template.render(context)
|
||||
|
||||
def render_to_response(
|
||||
self,
|
||||
context_data: Dict[str, Any],
|
||||
slots_data: Optional[Dict[SlotName, str]] = None,
|
||||
escape_slots_content: Optional[bool] = True,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
return HttpResponse(
|
||||
self.render(context_data, slots_data, escape_slots_content),
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _fill_slots(
|
||||
self,
|
||||
slots_data: Dict[SlotName, str],
|
||||
escape_content: bool,
|
||||
):
|
||||
"""Fill component slots outside of template rendering."""
|
||||
self.fill_content = [
|
||||
(
|
||||
slot_name,
|
||||
TextNode(escape(content) if escape_content else content),
|
||||
None,
|
||||
)
|
||||
for (slot_name, content) in slots_data.items()
|
||||
]
|
||||
|
||||
def _process_template_and_update_filled_slot_context(
|
||||
self, context: Context, template: Template
|
||||
self,
|
||||
context: Context,
|
||||
template: Template,
|
||||
) -> FilledSlotsContext:
|
||||
if isinstance(self.fill_content, NodeList):
|
||||
default_fill_content = (self.fill_content, None)
|
||||
|
|
|
@ -11,11 +11,14 @@ class ComponentRegistry(object):
|
|||
self._registry = {} # component name -> component_class mapping
|
||||
|
||||
def register(self, name=None, component=None):
|
||||
if name in self._registry:
|
||||
existing_component = self._registry.get(name)
|
||||
if (
|
||||
existing_component
|
||||
and existing_component.class_hash != component.class_hash
|
||||
):
|
||||
raise AlreadyRegistered(
|
||||
'The component "%s" is already registered' % name
|
||||
'The component "%s" has already been registered' % name
|
||||
)
|
||||
|
||||
self._registry[name] = component
|
||||
|
||||
def unregister(self, name):
|
||||
|
|
|
@ -6,7 +6,11 @@
|
|||
</head>
|
||||
<body>
|
||||
{% component "calendar" date=date %}
|
||||
{% component "greeting" greet='Hello world' %}
|
||||
{% component_block "greeting" name='Joe' %}
|
||||
{% fill "message" %}
|
||||
Howdy?
|
||||
{% endfill %}
|
||||
{% endcomponent_block %}
|
||||
{% component_js_dependencies %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
0
sampleproject/components/__init__.py
Normal file
0
sampleproject/components/__init__.py
Normal file
|
@ -1,13 +1,21 @@
|
|||
from typing import Any, Dict
|
||||
|
||||
from django_components import component
|
||||
|
||||
|
||||
@component.register("greeting")
|
||||
class greeting(component.Component):
|
||||
def get_context_data(self, greet, *args, **kwargs):
|
||||
return {"greet": greet}
|
||||
class Greeting(component.Component):
|
||||
def get(self, request, *args, **kwargs):
|
||||
slots = {"message": "Hello, world!"}
|
||||
context = {"name": request.GET.get("name", "")}
|
||||
return self.render_to_response(context, slots)
|
||||
|
||||
def get_context_data(self, name, *args, **kwargs) -> Dict[str, Any]:
|
||||
return {"name": name}
|
||||
|
||||
template = """
|
||||
<div id="greeting">{{ greet }}</div>
|
||||
<div id="greeting">Hello, {{ name }}!</div>
|
||||
{% slot "message" %}{% endslot %}
|
||||
"""
|
||||
|
||||
css = """
|
||||
|
|
6
sampleproject/components/urls.py
Normal file
6
sampleproject/components/urls.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.urls import path
|
||||
from greeting import Greeting
|
||||
|
||||
urlpatterns = [
|
||||
path("greeting/", Greeting.as_view(), name="greeting"),
|
||||
]
|
|
@ -2,4 +2,5 @@ from django.urls import include, path
|
|||
|
||||
urlpatterns = [
|
||||
path("", include("calendarapp.urls")),
|
||||
path("", include("components.urls")),
|
||||
]
|
||||
|
|
173
tests/test_component_as_view.py
Normal file
173
tests/test_component_as_view.py
Normal file
|
@ -0,0 +1,173 @@
|
|||
from typing import Any, Dict
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.template import Context, Template
|
||||
from django.test import Client
|
||||
from django.urls import include, path
|
||||
|
||||
# isort: off
|
||||
from .django_test_setup import * # noqa
|
||||
from .testutils import Django30CompatibleSimpleTestCase as SimpleTestCase
|
||||
|
||||
# isort: on
|
||||
|
||||
from django_components import component
|
||||
|
||||
|
||||
@component.register("testcomponent")
|
||||
class MockComponentRequest(component.Component):
|
||||
template = """
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="text" name="variable" value="{{ variable }}">
|
||||
<input type="submit">
|
||||
</form>
|
||||
"""
|
||||
|
||||
def post(self, request, *args, **kwargs) -> HttpResponse:
|
||||
variable = request.POST.get("variable")
|
||||
return self.render_to_response({"variable": variable})
|
||||
|
||||
def get(self, request, *args, **kwargs) -> HttpResponse:
|
||||
return self.render_to_response({"variable": "GET"})
|
||||
|
||||
def get_context_data(self, variable, *args, **kwargs) -> Dict[str, Any]:
|
||||
return {"variable": variable}
|
||||
|
||||
|
||||
@component.register("testcomponent_slot")
|
||||
class MockComponentSlot(component.Component):
|
||||
template = """
|
||||
{% load component_tags %}
|
||||
<div>
|
||||
{% slot "first_slot" %}
|
||||
Hey, I'm {{ name }}
|
||||
{% endslot %}
|
||||
{% slot "second_slot" %}
|
||||
{% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs) -> HttpResponse:
|
||||
return self.render_to_response(
|
||||
{"name": "Bob"}, {"second_slot": "Nice to meet you, Bob"}
|
||||
)
|
||||
|
||||
|
||||
@component.register("testcomponent_context_insecure")
|
||||
class MockInsecureComponentContext(component.Component):
|
||||
template = """
|
||||
{% load component_tags %}
|
||||
<div>
|
||||
{{ variable }}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs) -> HttpResponse:
|
||||
return self.render_to_response(
|
||||
{"variable": "<script>alert(1);</script>"}
|
||||
)
|
||||
|
||||
|
||||
@component.register("testcomponent_slot_insecure")
|
||||
class MockInsecureComponentSlot(component.Component):
|
||||
template = """
|
||||
{% load component_tags %}
|
||||
<div>
|
||||
{% slot "test_slot" %}
|
||||
{% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs) -> HttpResponse:
|
||||
return self.render_to_response(
|
||||
{}, {"test_slot": "<script>alert(1);</script>"}
|
||||
)
|
||||
|
||||
|
||||
def render_template_view(request):
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "testcomponent" variable="TEMPLATE" %}
|
||||
"""
|
||||
)
|
||||
return HttpResponse(template.render(Context({})))
|
||||
|
||||
|
||||
components_urlpatterns = [
|
||||
path("test/", MockComponentRequest.as_view()),
|
||||
path("test_slot/", MockComponentSlot.as_view()),
|
||||
path("test_context_insecure/", MockInsecureComponentContext.as_view()),
|
||||
path("test_slot_insecure/", MockInsecureComponentSlot.as_view()),
|
||||
path("test_template/", render_template_view),
|
||||
]
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(components_urlpatterns)),
|
||||
]
|
||||
|
||||
|
||||
class CustomClient(Client):
|
||||
def __init__(self, *args, **kwargs):
|
||||
settings.ROOT_URLCONF = __name__ # noqa
|
||||
settings.SECRET_KEY = "secret" # noqa
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class TestComponentAsView(SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.client = CustomClient()
|
||||
|
||||
def test_render_component_from_template(self):
|
||||
response = self.client.get("/test_template/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
b'<input type="text" name="variable" value="TEMPLATE">',
|
||||
response.content,
|
||||
)
|
||||
|
||||
def test_get_request(self):
|
||||
response = self.client.get("/test/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
b'<input type="text" name="variable" value="GET">',
|
||||
response.content,
|
||||
)
|
||||
|
||||
def test_post_request(self):
|
||||
response = self.client.post("/test/", {"variable": "POST"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
b'<input type="text" name="variable" value="POST">',
|
||||
response.content,
|
||||
)
|
||||
|
||||
def test_replace_slot_in_view(self):
|
||||
response = self.client.get("/test_slot/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
b"Hey, I'm Bob",
|
||||
response.content,
|
||||
)
|
||||
self.assertIn(
|
||||
b"Nice to meet you, Bob",
|
||||
response.content,
|
||||
)
|
||||
|
||||
def test_replace_slot_in_view_with_insecure_content(self):
|
||||
response = self.client.get("/test_slot_insecure/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn(
|
||||
b"<script>",
|
||||
response.content,
|
||||
)
|
||||
|
||||
def test_replace_context_in_view_with_insecure_content(self):
|
||||
response = self.client.get("/test_slot_insecure/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn(
|
||||
b"<script>",
|
||||
response.content,
|
||||
)
|
|
@ -5,10 +5,19 @@ from django_components import component
|
|||
from .django_test_setup import * # NOQA
|
||||
|
||||
|
||||
class MockComponent(object):
|
||||
class MockComponent(component.Component):
|
||||
pass
|
||||
|
||||
|
||||
class MockComponent2(component.Component):
|
||||
pass
|
||||
|
||||
|
||||
class MockComponentView(component.Component):
|
||||
def get(self, request, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class ComponentRegistryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.registry = component.ComponentRegistry()
|
||||
|
@ -37,13 +46,24 @@ class ComponentRegistryTest(unittest.TestCase):
|
|||
},
|
||||
)
|
||||
|
||||
def test_prevent_registering_twice(self):
|
||||
def test_prevent_registering_different_components_with_the_same_name(self):
|
||||
self.registry.register(name="testcomponent", component=MockComponent)
|
||||
with self.assertRaises(component.AlreadyRegistered):
|
||||
self.registry.register(
|
||||
name="testcomponent", component=MockComponent
|
||||
name="testcomponent", component=MockComponent2
|
||||
)
|
||||
|
||||
def test_allow_duplicated_registration_of_the_same_component(self):
|
||||
try:
|
||||
self.registry.register(
|
||||
name="testcomponent", component=MockComponentView
|
||||
)
|
||||
self.registry.register(
|
||||
name="testcomponent", component=MockComponentView
|
||||
)
|
||||
except component.AlreadyRegistered:
|
||||
self.fail("Should not raise AlreadyRegistered")
|
||||
|
||||
def test_simple_unregister(self):
|
||||
self.registry.register(name="testcomponent", component=MockComponent)
|
||||
self.registry.unregister(name="testcomponent")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue