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 - Overhauled typing system
- Middleware removed, no longer needed - 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. - Slots API polished and prepared for v1.
- Merged `Component.Url` with `Component.View` - Merged `Component.Url` with `Component.View`
- Added `Component.args`, `Component.kwargs`, `Component.slots` - 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/). 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
- 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)). - 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 # Prepend built-in extensions
from django_components.extensions.cache import CacheExtension 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.defaults import DefaultsExtension
from django_components.extensions.view import ViewExtension 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. # Extensions may be passed in either as classes or import strings.
extension_instances: List["ComponentExtension"] = [] extension_instances: List["ComponentExtension"] = []

View file

@ -64,8 +64,8 @@ class HtmlAttrsNode(BaseNode):
<div class="my-class extra-class" data-id="123"> <div class="my-class extra-class" data-id="123">
``` ```
**See more usage examples in See more usage examples in
[HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).** [HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).
""" """
tag = "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.test.signals import template_rendered
from django.views import View 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_media import ComponentMediaInput, ComponentMediaMeta
from django_components.component_registry import ComponentRegistry from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as registry_ from django_components.component_registry import registry as registry_
@ -74,7 +74,6 @@ from django_components.slots import (
resolve_fills, resolve_fills,
) )
from django_components.template import cached_template 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.context import gen_context_processors_data, snapshot_context
from django_components.util.django_monkeypatch import is_template_cls_patched from django_components.util.django_monkeypatch import is_template_cls_patched
from django_components.util.exception import component_error_message 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] del component_context_cache[render_id] # type: ignore[arg-type]
unregister_provide_reference(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( html = extensions.on_component_rendered(
OnComponentRenderedContext( OnComponentRenderedContext(
component=self, component=self,

View file

@ -14,6 +14,7 @@ from django_components.util.routing import URLRoute
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components import Component from django_components import Component
from django_components.component_registry import ComponentRegistry from django_components.component_registry import ComponentRegistry
from django_components.slots import Slot, SlotResult
TCallable = TypeVar("TCallable", bound=Callable) TCallable = TypeVar("TCallable", bound=Callable)
@ -128,6 +129,26 @@ class OnComponentRenderedContext(NamedTuple):
"""The rendered component""" """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 # EXTENSIONS CORE
################################################ ################################################
@ -529,6 +550,31 @@ class ComponentExtension:
""" """
pass 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. # Decorator to store events in `ExtensionManager._events` when django_components is not yet initialized.
def store_events(func: TCallable) -> TCallable: def store_events(func: TCallable) -> TCallable:
@ -846,6 +892,17 @@ class ExtensionManager:
ctx = ctx._replace(result=result) ctx = ctx._replace(result=result)
return ctx.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` # NOTE: This is a singleton which is takes the extensions from `app_settings.EXTENSIONS`
extensions = ExtensionManager() 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) 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") raise RuntimeError("Component URL is not available - Component is not public")
route_name = _get_component_route_name(component) route_name = _get_component_route_name(component)
@ -235,7 +235,7 @@ class ViewExtension(ComponentExtension):
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None: def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
comp_cls = ctx.component_cls comp_cls = ctx.component_cls
view_cls: Optional[Type[ComponentView]] = getattr(comp_cls, "View", None) 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 return
# Create a URL route like `components/MyTable_a1b2c3/` # Create a URL route like `components/MyTable_a1b2c3/`
@ -259,3 +259,9 @@ class ViewExtension(ComponentExtension):
if route is None: if route is None:
return return
extensions.remove_extension_urls(self.name, [route]) 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.html import conditional_escape
from django.utils.safestring import SafeString, mark_safe 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.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.node import BaseNode
from django_components.perfutil.component import component_context_cache 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.exception import add_slot_to_error_message
from django_components.util.logger import trace_component_msg from django_components.util.logger import trace_component_msg
from django_components.util.misc import get_index, get_last_index, is_identifier 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. # the render function ALWAYS receives them.
output = slot(data=kwargs, fallback=fallback, context=used_ctx) output = slot(data=kwargs, fallback=fallback, context=used_ctx)
if app_settings.DEBUG_HIGHLIGHT_SLOTS: # Allow plugins to post-process the slot's rendered output
output = apply_component_highlight("slot", output, f"{component_name} - {slot_name}") 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( trace_component_msg(
"RENDER_SLOT_END", "RENDER_SLOT_END",
@ -1104,9 +1114,9 @@ class FillNode(BaseNode):
# ... # ...
# {% endfill %} # {% endfill %}
# {% endfor %} # {% 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( raise RuntimeError(
"FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. " "FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. "
"Make sure that the {% fill %} tags are nested within {% component %} tags." "Make sure that the {% fill %} tags are nested within {% component %} tags."
@ -1166,7 +1176,7 @@ class FillNode(BaseNode):
layer["forloop"] = layer["forloop"].copy() layer["forloop"] = layer["forloop"].copy()
data.extra_context.update(layer) 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 import sys
from io import StringIO from io import StringIO
from textwrap import dedent from textwrap import dedent
@ -97,7 +98,14 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list") call_command("components", "ext", "list")
output = out.getvalue() 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( @djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]}, components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -108,7 +116,16 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list") call_command("components", "ext", "list")
output = out.getvalue() 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( @djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]}, components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -119,7 +136,16 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--all") call_command("components", "ext", "list", "--all")
output = out.getvalue() 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( @djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]}, components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -130,7 +156,16 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--columns", "name") call_command("components", "ext", "list", "--columns", "name")
output = out.getvalue() 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( @djc_test(
components_settings={"extensions": [EmptyExtension, DummyExtension]}, components_settings={"extensions": [EmptyExtension, DummyExtension]},
@ -141,7 +176,14 @@ class TestExtensionsListCommand:
call_command("components", "ext", "list", "--simple") call_command("components", "ext", "list", "--simple")
output = out.getvalue() 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 @djc_test
@ -155,11 +197,16 @@ class TestExtensionsRunCommand:
call_command("components", "ext", "run") call_command("components", "ext", "run")
output = out.getvalue() 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 ( assert (
output output
== dedent( == dedent(
f""" 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. Run a command added by an extension.
@ -167,10 +214,11 @@ class TestExtensionsRunCommand:
-h, --help show this help message and exit -h, --help show this help message and exit
subcommands: subcommands:
{{cache,defaults,view,empty,dummy}} {{cache,defaults,view,debug_highlight,empty,dummy}}
cache Run commands added by the 'cache' extension. cache Run commands added by the 'cache' extension.
defaults Run commands added by the 'defaults' extension. defaults Run commands added by the 'defaults' extension.
view Run commands added by the 'view' 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. empty Run commands added by the 'empty' extension.
dummy Run commands added by the 'dummy' 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 django_components.testing import djc_test
from .testutils import setup_test_config from .testutils import setup_test_config
setup_test_config({"autodiscover": False}) 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 @djc_test
class TestComponentHighlight: class TestComponentHighlight:
def test_component_highlight(self): def test_component_highlight_fn(self):
# Test component highlighting # Test component highlighting
test_html = "<div>Test content</div>" test_html = "<div>Test content</div>"
component_name = "TestComponent" component_name = "TestComponent"
@ -22,7 +68,7 @@ class TestComponentHighlight:
assert COLORS["component"].text_color in result assert COLORS["component"].text_color in result
assert COLORS["component"].border_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 slot highlighting
test_html = "<span>Slot content</span>" test_html = "<span>Slot content</span>"
slot_name = "content-slot" slot_name = "content-slot"
@ -35,3 +81,213 @@ class TestComponentHighlight:
# Check that the slot colors are used # Check that the slot colors are used
assert COLORS["slot"].text_color in result assert COLORS["slot"].text_color in result
assert COLORS["slot"].border_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, OnComponentDataContext,
) )
from django_components.extensions.cache import CacheExtension 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.defaults import DefaultsExtension
from django_components.extensions.view import ViewExtension from django_components.extensions.view import ViewExtension
@ -132,11 +133,12 @@ def with_registry(on_created: Callable):
class TestExtension: class TestExtension:
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_extensions_setting(self): 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[0], CacheExtension)
assert isinstance(app_settings.EXTENSIONS[1], DefaultsExtension) assert isinstance(app_settings.EXTENSIONS[1], DefaultsExtension)
assert isinstance(app_settings.EXTENSIONS[2], ViewExtension) 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]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_access_component_from_extension(self): def test_access_component_from_extension(self):
@ -175,7 +177,7 @@ class TestExtension:
class TestExtensionHooks: class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_component_class_lifecycle_hooks(self): 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_created"]) == 0
assert len(extension.calls["on_component_class_deleted"]) == 0 assert len(extension.calls["on_component_class_deleted"]) == 0
@ -207,7 +209,7 @@ class TestExtensionHooks:
@djc_test(components_settings={"extensions": [DummyExtension]}) @djc_test(components_settings={"extensions": [DummyExtension]})
def test_registry_lifecycle_hooks(self): 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_created"]) == 0
assert len(extension.calls["on_registry_deleted"]) == 0 assert len(extension.calls["on_registry_deleted"]) == 0
@ -244,7 +246,7 @@ class TestExtensionHooks:
return {"name": kwargs.get("name", "World")} return {"name": kwargs.get("name", "World")}
registry.register("test_comp", TestComponent) 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 # Verify on_component_registered was called
assert len(extension.calls["on_component_registered"]) == 1 assert len(extension.calls["on_component_registered"]) == 1
@ -282,7 +284,7 @@ class TestExtensionHooks:
test_slots = {"content": "Some content"} test_slots = {"content": "Some content"}
TestComponent.render(context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots) 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 # Verify on_component_input was called with correct args
assert len(extension.calls["on_component_input"]) == 1 assert len(extension.calls["on_component_input"]) == 1