feat: on_slot_rendered extension hook + refactor debug highlight as extension (#1209)

* feat: on_slot_rendered extension hook + refactor debug highlight as extension

* refactor: fix whitespace in test output
This commit is contained in:
Juro Oravec 2025-05-25 11:20:32 +02:00 committed by GitHub
parent 223fc2c68c
commit 6ff2d78a2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 560 additions and 77 deletions

View file

@ -8,7 +8,8 @@ Summary:
- Overhauled typing system
- Middleware removed, no longer needed
- `get_template_data()` is the new canonical way to define template data
- `get_template_data()` is the new canonical way to define template data.
`get_context_data()` is now deprecated but will remain until v2.
- Slots API polished and prepared for v1.
- Merged `Component.Url` with `Component.View`
- Added `Component.args`, `Component.kwargs`, `Component.slots`
@ -756,6 +757,24 @@ Summary:
Read more on [Component caching](https://django-components.github.io/django-components/0.140/concepts/advanced/component_caching/).
- New extension hook `on_slot_rendered()`
This hook is called when a slot is rendered, and allows you to access and/or modify the rendered result.
This is used by the ["debug highlight" feature](https://django-components.github.io/django-components/0.140/guides/other/troubleshooting/#component-and-slot-highlighting).
To modify the rendered result, return the new value:
```py
class MyExtension(ComponentExtension):
def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]:
return ctx.result + "<!-- Hello, world! -->"
```
If you don't want to modify the rendered result, return `None`.
See all [Extension hooks](https://django-components.github.io/django-components/0.140/reference/extension_hooks/).
#### Fix
- Fix bug: Context processors data was being generated anew for each component. Now the data is correctly created once and reused across components with the same request ([#1165](https://github.com/django-components/django-components/issues/1165)).

View file

@ -751,10 +751,11 @@ class InternalSettings:
# Prepend built-in extensions
from django_components.extensions.cache import CacheExtension
from django_components.extensions.debug_highlight import DebugHighlightExtension
from django_components.extensions.defaults import DefaultsExtension
from django_components.extensions.view import ViewExtension
extensions = [CacheExtension, DefaultsExtension, ViewExtension] + list(extensions)
extensions = [CacheExtension, DefaultsExtension, ViewExtension, DebugHighlightExtension] + list(extensions)
# Extensions may be passed in either as classes or import strings.
extension_instances: List["ComponentExtension"] = []

View file

@ -64,8 +64,8 @@ class HtmlAttrsNode(BaseNode):
<div class="my-class extra-class" data-id="123">
```
**See more usage examples in
[HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).**
See more usage examples in
[HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).
"""
tag = "html_attrs"

View file

@ -31,7 +31,7 @@ from django.template.loader_tags import BLOCK_CONTEXT_KEY, BlockContext
from django.test.signals import template_rendered
from django.views import View
from django_components.app_settings import ContextBehavior, app_settings
from django_components.app_settings import ContextBehavior
from django_components.component_media import ComponentMediaInput, ComponentMediaMeta
from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as registry_
@ -74,7 +74,6 @@ from django_components.slots import (
resolve_fills,
)
from django_components.template import cached_template
from django_components.util.component_highlight import apply_component_highlight
from django_components.util.context import gen_context_processors_data, snapshot_context
from django_components.util.django_monkeypatch import is_template_cls_patched
from django_components.util.exception import component_error_message
@ -2943,9 +2942,6 @@ class Component(metaclass=ComponentMeta):
del component_context_cache[render_id] # type: ignore[arg-type]
unregister_provide_reference(render_id) # type: ignore[arg-type]
if app_settings.DEBUG_HIGHLIGHT_COMPONENTS:
html = apply_component_highlight("component", html, f"{self.name} ({render_id})")
html = extensions.on_component_rendered(
OnComponentRenderedContext(
component=self,

View file

@ -14,6 +14,7 @@ from django_components.util.routing import URLRoute
if TYPE_CHECKING:
from django_components import Component
from django_components.component_registry import ComponentRegistry
from django_components.slots import Slot, SlotResult
TCallable = TypeVar("TCallable", bound=Callable)
@ -128,6 +129,26 @@ class OnComponentRenderedContext(NamedTuple):
"""The rendered component"""
# TODO - Add `component` once we create instances inside `render()`
# See https://github.com/django-components/django-components/issues/1186
@mark_extension_hook_api
class OnSlotRenderedContext(NamedTuple):
component_cls: Type["Component"]
"""The Component class that contains the `{% slot %}` tag"""
component_id: str
"""The unique identifier for this component instance"""
slot: "Slot"
"""The Slot instance that was rendered"""
slot_name: str
"""The name of the `{% slot %}` tag"""
slot_is_required: bool
"""Whether the slot is required"""
slot_is_default: bool
"""Whether the slot is default"""
result: "SlotResult"
"""The rendered result of the slot"""
################################################
# EXTENSIONS CORE
################################################
@ -529,6 +550,31 @@ class ComponentExtension:
"""
pass
##########################
# Tags lifecycle hooks
##########################
def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]:
"""
Called when a [`{% slot %}`](../template_tags#slot) tag was rendered.
Use this hook to access or post-process the slot's rendered output.
To modify the output, return a new string from this hook.
**Example:**
```python
from django_components import ComponentExtension, OnSlotRenderedContext
class MyExtension(ComponentExtension):
def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]:
# Append a comment to the slot's rendered output
return ctx.result + "<!-- MyExtension comment -->"
```
"""
pass
# Decorator to store events in `ExtensionManager._events` when django_components is not yet initialized.
def store_events(func: TCallable) -> TCallable:
@ -846,6 +892,17 @@ class ExtensionManager:
ctx = ctx._replace(result=result)
return ctx.result
##########################
# Tags lifecycle hooks
##########################
def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]:
for extension in self.extensions:
result = extension.on_slot_rendered(ctx)
if result is not None:
ctx = ctx._replace(result=result)
return ctx.result
# NOTE: This is a singleton which is takes the extensions from `app_settings.EXTENSIONS`
extensions = ExtensionManager()

View file

@ -0,0 +1,131 @@
from typing import Any, Literal, NamedTuple, Optional, Type
from django_components.app_settings import app_settings
from django_components.extension import ComponentExtension, OnComponentRenderedContext, OnSlotRenderedContext
from django_components.util.misc import gen_id
class HighlightColor(NamedTuple):
text_color: str
border_color: str
COLORS = {
"component": HighlightColor(text_color="#2f14bb", border_color="blue"),
"slot": HighlightColor(text_color="#bb1414", border_color="#e40c0c"),
}
def apply_component_highlight(type: Literal["component", "slot"], output: str, name: str) -> str:
"""
Wrap HTML (string) in a div with a border and a highlight color.
This is part of the component / slot highlighting feature. User can toggle on
to see the component / slot boundaries.
"""
color = COLORS[type]
# Because the component / slot name is set via styling as a `::before` pseudo-element,
# we need to generate a unique ID for each component / slot to avoid conflicts.
highlight_id = gen_id()
output = f"""
<style>
.{type}-highlight-{highlight_id}::before {{
content: "{name}: ";
font-weight: bold;
color: {color.text_color};
}}
</style>
<div class="{type}-highlight-{highlight_id}" style="border: 1px solid {color.border_color}">
{output}
</div>
"""
return output
# TODO - Deprecate `DEBUG_HIGHLIGHT_SLOTS` and `DEBUG_HIGHLIGHT_COMPONENTS` (with removal in v1)
# once `extension_defaults` is implemented.
# That way people will be able to set the highlighting from single place.
# At that point also document this extension in the docs:
# - Exposing `ComponentDebugHighlight` from `__init__.py`
# - Adding `Component.DebugHighlight` and `Component.debug_highlight` attributes to Component class
# so it's easier to find.
# - Check docstring of `ComponentDebugHighlight` in the docs and make sure it's correct.
class HighlightComponentsDescriptor:
def __get__(self, obj: Optional[Any], objtype: Type) -> bool:
return app_settings.DEBUG_HIGHLIGHT_COMPONENTS
class HighlightSlotsDescriptor:
def __get__(self, obj: Optional[Any], objtype: Type) -> bool:
return app_settings.DEBUG_HIGHLIGHT_SLOTS
class ComponentDebugHighlight(ComponentExtension.ExtensionClass): # type: ignore
"""
The interface for `Component.DebugHighlight`.
The fields of this class are used to configure the component debug highlighting for this component
and its direct slots.
Read more about [Component debug highlighting](../../concepts/advanced/component_debug_highlighting).
**Example:**
```python
from django_components import Component
class MyComponent(Component):
class DebugHighlight:
highlight_components = True
highlight_slots = True
```
To highlight ALL components and slots, set
[`ComponentsSettings.DEBUG_HIGHLIGHT_SLOTS`](../../settings/components_settings.md#debug_highlight_slots) and
[`ComponentsSettings.DEBUG_HIGHLIGHT_COMPONENTS`](../../settings/components_settings.md#debug_highlight_components)
to `True`.
"""
# TODO_v1 - Remove `DEBUG_HIGHLIGHT_COMPONENTS` and `DEBUG_HIGHLIGHT_SLOTS`
# Instead set this as plain boolean fields.
highlight_components = HighlightComponentsDescriptor()
"""Whether to highlight this component in the rendered output."""
highlight_slots = HighlightSlotsDescriptor()
"""Whether to highlight slots of this component in the rendered output."""
# TODO_v1 - Move into standalone extension (own repo?) and ask people to manually add this extension in settings.
class DebugHighlightExtension(ComponentExtension):
"""
This extension adds the ability to highlight components and slots in the rendered output.
To highlight slots, set `ComponentsSettings.DEBUG_HIGHLIGHT_SLOTS` to `True` in your settings.
To highlight components, set `ComponentsSettings.DEBUG_HIGHLIGHT_COMPONENTS` to `True`.
Highlighting is done by wrapping the content in a `<div>` with a border and a highlight color.
This extension is automatically added to all components.
"""
name = "debug_highlight"
ExtensionClass = ComponentDebugHighlight
# Apply highlight to the slot's rendered output
def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]:
debug_cls: Optional[ComponentDebugHighlight] = getattr(ctx.component_cls, "DebugHighlight", None)
if not debug_cls or not debug_cls.highlight_slots:
return None
return apply_component_highlight("slot", ctx.result, f"{ctx.component_cls.__name__} - {ctx.slot_name}")
# Apply highlight to the rendered component
def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
debug_cls: Optional[ComponentDebugHighlight] = getattr(ctx.component_cls, "DebugHighlight", None)
if not debug_cls or not debug_cls.highlight_components:
return None
return apply_component_highlight("component", ctx.result, f"{ctx.component.name} ({ctx.component_id})")

View file

@ -66,7 +66,7 @@ def get_component_url(
```
"""
view_cls: Optional[Type[ComponentView]] = getattr(component, "View", None)
if view_cls is None or not view_cls.public:
if not _is_view_public(view_cls):
raise RuntimeError("Component URL is not available - Component is not public")
route_name = _get_component_route_name(component)
@ -235,7 +235,7 @@ class ViewExtension(ComponentExtension):
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
comp_cls = ctx.component_cls
view_cls: Optional[Type[ComponentView]] = getattr(comp_cls, "View", None)
if view_cls is None or not view_cls.public:
if not _is_view_public(view_cls):
return
# Create a URL route like `components/MyTable_a1b2c3/`
@ -259,3 +259,9 @@ class ViewExtension(ComponentExtension):
if route is None:
return
extensions.remove_extension_urls(self.name, [route])
def _is_view_public(view_cls: Optional[Type[ComponentView]]) -> bool:
if view_cls is None:
return False
return getattr(view_cls, "public", False)

View file

@ -26,11 +26,11 @@ from django.template.exceptions import TemplateSyntaxError
from django.utils.html import conditional_escape
from django.utils.safestring import SafeString, mark_safe
from django_components.app_settings import ContextBehavior, app_settings
from django_components.app_settings import ContextBehavior
from django_components.context import _COMPONENT_CONTEXT_KEY, _INJECT_CONTEXT_KEY_PREFIX
from django_components.extension import OnSlotRenderedContext, extensions
from django_components.node import BaseNode
from django_components.perfutil.component import component_context_cache
from django_components.util.component_highlight import apply_component_highlight
from django_components.util.exception import add_slot_to_error_message
from django_components.util.logger import trace_component_msg
from django_components.util.misc import get_index, get_last_index, is_identifier
@ -851,8 +851,18 @@ class SlotNode(BaseNode):
# the render function ALWAYS receives them.
output = slot(data=kwargs, fallback=fallback, context=used_ctx)
if app_settings.DEBUG_HIGHLIGHT_SLOTS:
output = apply_component_highlight("slot", output, f"{component_name} - {slot_name}")
# Allow plugins to post-process the slot's rendered output
output = extensions.on_slot_rendered(
OnSlotRenderedContext(
component_cls=component_ctx.component_class,
component_id=component_ctx.component_id,
slot=slot,
slot_name=slot_name,
slot_is_required=is_required,
slot_is_default=is_default,
result=output,
),
)
trace_component_msg(
"RENDER_SLOT_END",
@ -1104,9 +1114,9 @@ class FillNode(BaseNode):
# ...
# {% endfill %}
# {% endfor %}
collected_fills: Optional[List[FillWithData]] = context.get(FILL_GEN_CONTEXT_KEY, None)
captured_fills: Optional[List[FillWithData]] = context.get(FILL_GEN_CONTEXT_KEY, None)
if collected_fills is None:
if captured_fills is None:
raise RuntimeError(
"FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. "
"Make sure that the {% fill %} tags are nested within {% component %} tags."
@ -1166,7 +1176,7 @@ class FillNode(BaseNode):
layer["forloop"] = layer["forloop"].copy()
data.extra_context.update(layer)
collected_fills.append(data)
captured_fills.append(data)
#######################################

View file

@ -1,43 +0,0 @@
from typing import Literal, NamedTuple
from django_components.util.misc import gen_id
class HighlightColor(NamedTuple):
text_color: str
border_color: str
COLORS = {
"component": HighlightColor(text_color="#2f14bb", border_color="blue"),
"slot": HighlightColor(text_color="#bb1414", border_color="#e40c0c"),
}
def apply_component_highlight(type: Literal["component", "slot"], output: str, name: str) -> str:
"""
Wrap HTML (string) in a div with a border and a highlight color.
This is part of the component / slot highlighting feature. User can toggle on
to see the component / slot boundaries.
"""
color = COLORS[type]
# Because the component / slot name is set via styling as a `::before` pseudo-element,
# we need to generate a unique ID for each component / slot to avoid conflicts.
highlight_id = gen_id()
output = f"""
<style>
.{type}-highlight-{highlight_id}::before {{
content: "{name}: ";
font-weight: bold;
color: {color.text_color};
}}
</style>
<div class="{type}-highlight-{highlight_id}" style="border: 1px solid {color.border_color}">
{output}
</div>
"""
return output

View file

@ -1,3 +1,4 @@
import re
import sys
from io import StringIO
from textwrap import dedent
@ -97,7 +98,14 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list")
output = out.getvalue()
assert output.strip() == "name \n========\ncache \ndefaults\nview"
assert output.strip() == (
"name \n"
"===============\n"
"cache \n"
"defaults \n"
"view \n"
"debug_highlight"
)
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -108,7 +116,16 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list")
output = out.getvalue()
assert output.strip() == "name \n========\ncache \ndefaults\nview \nempty \ndummy"
assert output.strip() == (
"name \n"
"===============\n"
"cache \n"
"defaults \n"
"view \n"
"debug_highlight\n"
"empty \n"
"dummy"
)
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -119,7 +136,16 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--all")
output = out.getvalue()
assert output.strip() == "name \n========\ncache \ndefaults\nview \nempty \ndummy"
assert output.strip() == (
"name \n"
"===============\n"
"cache \n"
"defaults \n"
"view \n"
"debug_highlight\n"
"empty \n"
"dummy"
)
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -130,7 +156,16 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--columns", "name")
output = out.getvalue()
assert output.strip() == "name \n========\ncache \ndefaults\nview \nempty \ndummy"
assert output.strip() == (
"name \n"
"===============\n"
"cache \n"
"defaults \n"
"view \n"
"debug_highlight\n"
"empty \n"
"dummy"
)
@djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -141,7 +176,14 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--simple")
output = out.getvalue()
assert output.strip() == "cache \ndefaults\nview \nempty \ndummy"
assert output.strip() == (
"cache \n"
"defaults \n"
"view \n"
"debug_highlight\n"
"empty \n"
"dummy"
)
@djc_test
@ -155,11 +197,16 @@ class TestExtensionsRunCommand:
call_command("components", "ext", "run")
output = out.getvalue()
# Fix line breaking in CI on the first line between the `[-h]` and `{{cmd_name}}`
output = re.compile(r"\]\s+\{").sub("] {", output)
# Fix line breaking in CI on the first line between the `{{cmd_name}}` and `...`
output = re.compile(r"\}\s+\.\.\.").sub("} ...", output)
assert (
output
== dedent(
f"""
usage: components ext run [-h] {{cache,defaults,view,empty,dummy}} ...
usage: components ext run [-h] {{cache,defaults,view,debug_highlight,empty,dummy}} ...
Run a command added by an extension.
@ -167,10 +214,11 @@ class TestExtensionsRunCommand:
-h, --help show this help message and exit
subcommands:
{{cache,defaults,view,empty,dummy}}
{{cache,defaults,view,debug_highlight,empty,dummy}}
cache Run commands added by the 'cache' extension.
defaults Run commands added by the 'defaults' extension.
view Run commands added by the 'view' extension.
debug_highlight Run commands added by the 'debug_highlight' extension.
empty Run commands added by the 'empty' extension.
dummy Run commands added by the 'dummy' extension.
"""

View file

@ -1,14 +1,60 @@
from django_components.util.component_highlight import apply_component_highlight, COLORS
from django.template import Context, Template
from pytest_django.asserts import assertHTMLEqual
from django_components import Component, register, types
from django_components.extensions.debug_highlight import apply_component_highlight, COLORS
from django_components.testing import djc_test
from .testutils import setup_test_config
setup_test_config({"autodiscover": False})
def _prepare_template() -> Template:
@register("inner")
class InnerComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div class="inner">
<div>
1: {% slot "content" default / %}
</div>
<div>
2: {% slot "content" default / %}
</div>
</div>
"""
@register("outer")
class OuterComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div class="outer">
{% component "inner" %}
{{ content }}
{% endcomponent %}
</div>
"""
def get_template_data(self, args, kwargs, slots, context):
return {
"content": kwargs["content"],
}
template_str: types.django_html = """
{% load component_tags %}
{% for item in items %}
<div class="item">
{% component "outer" content=item / %}
</div>
{% endfor %}
"""
template = Template(template_str)
return template
@djc_test
class TestComponentHighlight:
def test_component_highlight(self):
def test_component_highlight_fn(self):
# Test component highlighting
test_html = "<div>Test content</div>"
component_name = "TestComponent"
@ -22,7 +68,7 @@ class TestComponentHighlight:
assert COLORS["component"].text_color in result
assert COLORS["component"].border_color in result
def test_slot_highlight(self):
def test_slot_highlight_fn(self):
# Test slot highlighting
test_html = "<span>Slot content</span>"
slot_name = "content-slot"
@ -35,3 +81,213 @@ class TestComponentHighlight:
# Check that the slot colors are used
assert COLORS["slot"].text_color in result
assert COLORS["slot"].border_color in result
@djc_test(components_settings={"debug_highlight_components": True})
def test_component_highlight_extension(self):
template = _prepare_template()
rendered = template.render(Context({"items": [1, 2]}))
expected = """
<div class="item">
<style>
.component-highlight-a1bc45::before {
content: "outer (ca1bc3f): ";
font-weight: bold;
color: #2f14bb;
}
</style>
<div class="component-highlight-a1bc45" style="border: 1px solid blue">
<div class="outer" data-djc-id-ca1bc3f="">
<style>
.component-highlight-a1bc44::before {
content: "inner (ca1bc41): ";
font-weight: bold;
color: #2f14bb;
}
</style>
<div class="component-highlight-a1bc44" style="border: 1px solid blue">
<div class="inner" data-djc-id-ca1bc41="">
<div>
1: 1
</div>
<div>
2: 1
</div>
</div>
</div>
</div>
</div>
</div>
<div class="item">
<style>
.component-highlight-a1bc49::before {
content: "outer (ca1bc46): ";
font-weight: bold;
color: #2f14bb;
}
</style>
<div class="component-highlight-a1bc49" style="border: 1px solid blue">
<div class="outer" data-djc-id-ca1bc46="">
<style>
.component-highlight-a1bc48::before {
content: "inner (ca1bc47): ";
font-weight: bold;
color: #2f14bb;
}
</style>
<div class="component-highlight-a1bc48" style="border: 1px solid blue">
<div class="inner" data-djc-id-ca1bc47="">
<div>
1: 2
</div>
<div>
2: 2
</div>
</div>
</div>
</div>
</div>
</div>
"""
assertHTMLEqual(rendered, expected)
@djc_test(components_settings={"debug_highlight_slots": True})
def test_slot_highlight_extension(self):
template = _prepare_template()
rendered = template.render(Context({"items": [1, 2]}))
expected = """
<div class="item">
<div class="outer" data-djc-id-ca1bc3f="">
<div class="inner" data-djc-id-ca1bc41="">
<div>
1:
<style>
.slot-highlight-a1bc44::before {
content: "InnerComponent - content: ";
font-weight: bold;
color: #bb1414;
}
</style>
<div class="slot-highlight-a1bc44" style="border: 1px solid #e40c0c">
1
</div>
</div>
<div>
2:
<style>
.slot-highlight-a1bc45::before {
content: "InnerComponent - content: ";
font-weight: bold;
color: #bb1414;
}
</style>
<div class="slot-highlight-a1bc45" style="border: 1px solid #e40c0c">
1
</div>
</div>
</div>
</div>
</div>
<div class="item">
<div class="outer" data-djc-id-ca1bc46="">
<div class="inner" data-djc-id-ca1bc47="">
<div>
1:
<style>
.slot-highlight-a1bc48::before {
content: "InnerComponent - content: ";
font-weight: bold;
color: #bb1414;
}
</style>
<div class="slot-highlight-a1bc48" style="border: 1px solid #e40c0c">
2
</div>
</div>
<div>
2:
<style>
.slot-highlight-a1bc49::before {
content: "InnerComponent - content: ";
font-weight: bold;
color: #bb1414;
}
</style>
<div class="slot-highlight-a1bc49" style="border: 1px solid #e40c0c">
2
</div>
</div>
</div>
</div>
</div>
"""
assertHTMLEqual(rendered, expected)
def test_highlight_on_component_class(self):
@register("inner")
class InnerComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div class="inner">
<div>
1: {% slot "content" default / %}
</div>
<div>
2: {% slot "content" default / %}
</div>
</div>
"""
class DebugHighlight:
highlight_components = True
highlight_slots = True
template = Template("""
{% load component_tags %}
{% component "inner" %}
{{ content }}
{% endcomponent %}
""")
rendered = template.render(Context({"content": "Hello, world!"}))
expected = """
<style>
.component-highlight-a1bc44::before {
content: "inner (ca1bc3f): ";
font-weight: bold;
color: #2f14bb;
}
</style>
<div class="component-highlight-a1bc44" style="border: 1px solid blue">
<div class="inner" data-djc-id-ca1bc3f="">
<div>
1:
<style>
.slot-highlight-a1bc42::before {
content: "InnerComponent - content: ";
font-weight: bold;
color: #bb1414;
}
</style>
<div class="slot-highlight-a1bc42" style="border: 1px solid #e40c0c">
Hello, world!
</div>
</div>
<div>
2:
<style>
.slot-highlight-a1bc43::before {
content: "InnerComponent - content: ";
font-weight: bold;
color: #bb1414;
}
</style>
<div class="slot-highlight-a1bc43" style="border: 1px solid #e40c0c">
Hello, world!
</div>
</div>
</div>
</div>
"""
assertHTMLEqual(rendered, expected)

View file

@ -22,6 +22,7 @@ from django_components.extension import (
OnComponentDataContext,
)
from django_components.extensions.cache import CacheExtension
from django_components.extensions.debug_highlight import DebugHighlightExtension
from django_components.extensions.defaults import DefaultsExtension
from django_components.extensions.view import ViewExtension
@ -132,11 +133,12 @@ def with_registry(on_created: Callable):
class TestExtension:
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_extensions_setting(self):
assert len(app_settings.EXTENSIONS) == 4
assert len(app_settings.EXTENSIONS) == 5
assert isinstance(app_settings.EXTENSIONS[0], CacheExtension)
assert isinstance(app_settings.EXTENSIONS[1], DefaultsExtension)
assert isinstance(app_settings.EXTENSIONS[2], ViewExtension)
assert isinstance(app_settings.EXTENSIONS[3], DummyExtension)
assert isinstance(app_settings.EXTENSIONS[3], DebugHighlightExtension)
assert isinstance(app_settings.EXTENSIONS[4], DummyExtension)
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_access_component_from_extension(self):
@ -175,7 +177,7 @@ class TestExtension:
class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_component_class_lifecycle_hooks(self):
extension = cast(DummyExtension, app_settings.EXTENSIONS[3])
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
assert len(extension.calls["on_component_class_created"]) == 0
assert len(extension.calls["on_component_class_deleted"]) == 0
@ -207,7 +209,7 @@ class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_registry_lifecycle_hooks(self):
extension = cast(DummyExtension, app_settings.EXTENSIONS[3])
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
assert len(extension.calls["on_registry_created"]) == 0
assert len(extension.calls["on_registry_deleted"]) == 0
@ -244,7 +246,7 @@ class TestExtensionHooks:
return {"name": kwargs.get("name", "World")}
registry.register("test_comp", TestComponent)
extension = cast(DummyExtension, app_settings.EXTENSIONS[3])
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
# Verify on_component_registered was called
assert len(extension.calls["on_component_registered"]) == 1
@ -282,7 +284,7 @@ class TestExtensionHooks:
test_slots = {"content": "Some content"}
TestComponent.render(context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots)
extension = cast(DummyExtension, app_settings.EXTENSIONS[3])
extension = cast(DummyExtension, app_settings.EXTENSIONS[4])
# Verify on_component_input was called with correct args
assert len(extension.calls["on_component_input"]) == 1