diff --git a/README.md b/README.md index 29d957d6..887091df 100644 --- a/README.md +++ b/README.md @@ -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:
+ hello from tab 1 +
+ {% 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. diff --git a/src/django_components/component.py b/src/django_components/component.py index 7d008b9d..ef4e636e 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -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: diff --git a/tests/test_component.py b/tests/test_component.py index 605bb2aa..df127f6d 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -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: 123", ) + + +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: + """, + )