mirror of
https://github.com/django-components/django-components.git
synced 2025-08-30 10:47:20 +00:00
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:
parent
223fc2c68c
commit
6ff2d78a2f
12 changed files with 560 additions and 77 deletions
21
CHANGELOG.md
21
CHANGELOG.md
|
@ -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)).
|
||||
|
|
|
@ -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"] = []
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
131
src/django_components/extensions/debug_highlight.py
Normal file
131
src/django_components/extensions/debug_highlight.py
Normal 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})")
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
#######################################
|
||||
|
|
|
@ -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
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue