From 0cfc40231bed770cbe00256b2da0b9a9d2f37828 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Sat, 31 Aug 2024 13:38:28 +0200 Subject: [PATCH] feat: add component hooks (#631) --- README.md | 87 ++++++++++++++++++ src/django_components/component.py | 26 ++++++ tests/test_component.py | 140 +++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) 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:
123
``` +## 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" %} +

+ 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: + """, + )