feat: add component hooks (#631)

This commit is contained in:
Juro Oravec 2024-08-31 13:38:28 +02:00 committed by GitHub
parent 3c6f478f8a
commit 0cfc40231b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 253 additions and 0 deletions

View file

@ -55,6 +55,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
- [Rendering HTML attributes](#rendering-html-attributes)
- [Template tag syntax](#template-tag-syntax)
- [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject)
- [Component hooks](#component-hooks)
- [Component context and scope](#component-context-and-scope)
- [Customizing component tags with TagFormatter](#customizing-component-tags-with-tagformatter)
- [Defining HTML/JS/CSS files](#defining-htmljscss-files)
@ -809,6 +810,8 @@ class MyComponent(Component):
### Adding type hints with Generics
_New in version 0.92_
The `Component` class optionally accepts type parameters
that allow you to specify the types of args, kwargs, slots, and
data:
@ -937,6 +940,8 @@ class Button(Component[Args, Kwargs, Data, Slots]):
### Runtime input validation with types
_New in version 0.96_
> NOTE: Kwargs, slots, and data validation is supported only for Python >=3.11
In Python 3.11 and later, when you specify the component types, you will get also runtime validation of the inputs you pass to `Component.render` or `Component.render_to_response`.
@ -2554,6 +2559,88 @@ renders:
<div>123</div>
```
## Component hooks
_New in version 0.96_
Component hooks are functions that allow you to intercept the rendering process at specific positions.
### Available hooks
- `on_render_before`
```py
def on_render_before(
self: Component,
context: Context,
template: Template
) -> None:
```
Hook that runs just before the component's template is rendered.
You can use this hook to access or modify the context or the template:
```py
def on_render_before(self, context, template) -> None:
# Insert value into the Context
context["from_on_before"] = ":)"
# Append text into the Template
template.nodelist.append(TextNode("FROM_ON_BEFORE"))
```
- `on_render_after`
```py
def on_render_after(
self: Component,
context: Context,
template: Template,
content: str
) -> None | str | SafeString:
```
Hook that runs just after the component's template was rendered.
It receives the rendered output as the last argument.
You can use this hook to access the context or the template, but modifying
them won't have any effect.
To override the content that gets rendered, you can return a string or SafeString from this hook:
```py
def on_render_after(self, context, template, content):
# Prepend text to the rendered content
return "Chocolate cookie recipe: " + content
```
### Component hooks example
You can use hooks together with [provide / inject](#how-to-use-provide--inject) to create components
that accept a list of items via a slot.
In the example below, each `tab_item` component will be rendered on a separate tab page, but they are all defined in the default slot of the `tabs` component.
[See here for how it was done](https://github.com/EmilStenstrom/django-components/discussions/540)
```django
{% component "tabs" %}
{% component "tab_item" header="Tab 1" %}
<p>
hello from tab 1
</p>
{% component "button" %}
Click me!
{% endcomponent %}
{% endcomponent %}
{% component "tab_item" header="Tab 2" %}
Hello this is tab 2
{% endcomponent %}
{% endcomponent %}
```
## Component context and scope
By default, context variables are passed down the template as in regular Django - deeper scopes can access the variables from the outer scopes. So if you have several nested forloops, then inside the deep-most loop you can access variables defined by all previous loops.

View file

@ -169,6 +169,27 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
View = ComponentView
def on_render_before(self, context: Context, template: Template) -> None:
"""
Hook that runs just before the component's template is rendered.
You can use this hook to access or modify the context or the template.
"""
pass
def on_render_after(self, context: Context, template: Template, content: str) -> Optional[SlotResult]:
"""
Hook that runs just after the component's template was rendered.
It receives the rendered output as the last argument.
You can use this hook to access the context or the template, but modifying
them won't have any effect.
To override the content that gets rendered, you can return a string or SafeString
from this hook.
"""
pass
def __init__(
self,
registered_name: Optional[str] = None,
@ -563,8 +584,13 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
},
}
):
self.on_render_before(context, template)
rendered_component = template.render(context)
new_output = self.on_render_after(context, template, rendered_component)
rendered_component = new_output if new_output is not None else rendered_component
if is_dependency_middleware_active():
output = RENDERED_COMMENT_TEMPLATE.format(name=self.name) + rendered_component
else:

View file

@ -17,6 +17,7 @@ from unittest import skipIf
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse
from django.template import Context, RequestContext, Template, TemplateSyntaxError
from django.template.base import TextNode
from django.utils.safestring import SafeString
from django_components import Component, SlotFunc, registry, types
@ -851,3 +852,142 @@ class ComponentRenderTest(BaseTestCase):
rendered_resp.content.decode("utf-8"),
"Variable: <strong>123</strong>",
)
class ComponentHookTest(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_on_render_before(self):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
args: {{ args|safe }}
kwargs: {{ kwargs|safe }}
---
from_on_before: {{ from_on_before }}
"""
def get_context_data(self, *args, **kwargs):
return {
"args": args,
"kwargs": kwargs,
}
def on_render_before(self, context: Context, template: Template) -> None:
# Insert value into the Context
context["from_on_before"] = ":)"
# Insert text into the Template
template.nodelist.append(TextNode("\n---\nFROM_ON_BEFORE"))
rendered = SimpleComponent.render()
self.assertHTMLEqual(
rendered,
"""
args: ()
kwargs: {}
---
from_on_before: :)
---
FROM_ON_BEFORE
""",
)
# Check that modifying the context or template does nothing
@parametrize_context_behavior(["django", "isolated"])
def test_on_render_after(self):
captured_content = None
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
args: {{ args|safe }}
kwargs: {{ kwargs|safe }}
---
from_on_before: {{ from_on_before }}
"""
def get_context_data(self, *args, **kwargs):
return {
"args": args,
"kwargs": kwargs,
}
# Check that modifying the context or template does nothing
def on_render_after(self, context: Context, template: Template, content: str) -> None:
# Insert value into the Context
context["from_on_before"] = ":)"
# Insert text into the Template
template.nodelist.append(TextNode("\n---\nFROM_ON_BEFORE"))
nonlocal captured_content
captured_content = content
rendered = SimpleComponent.render()
self.assertHTMLEqual(
captured_content,
"""
args: ()
kwargs: {}
---
from_on_before:
""",
)
self.assertHTMLEqual(
rendered,
"""
args: ()
kwargs: {}
---
from_on_before:
""",
)
# Check that modifying the context or template does nothing
@parametrize_context_behavior(["django", "isolated"])
def test_on_render_after_override_output(self):
captured_content = None
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
args: {{ args|safe }}
kwargs: {{ kwargs|safe }}
---
from_on_before: {{ from_on_before }}
"""
def get_context_data(self, *args, **kwargs):
return {
"args": args,
"kwargs": kwargs,
}
def on_render_after(self, context: Context, template: Template, content: str) -> str:
nonlocal captured_content
captured_content = content
return "Chocolate cookie recipe: " + content
rendered = SimpleComponent.render()
self.assertHTMLEqual(
captured_content,
"""
args: ()
kwargs: {}
---
from_on_before:
""",
)
self.assertHTMLEqual(
rendered,
"""
Chocolate cookie recipe:
args: ()
kwargs: {}
---
from_on_before:
""",
)