mirror of
https://github.com/django-components/django-components.git
synced 2025-08-31 11:17:21 +00:00
feat: Expose slot input as Slot.contents (#1180)
* feat: expose slot input as Slot.contents * refactor: fix linter errors
This commit is contained in:
parent
53d80684bb
commit
0d05ef4cb2
6 changed files with 576 additions and 222 deletions
30
CHANGELOG.md
30
CHANGELOG.md
|
@ -2,6 +2,8 @@
|
|||
|
||||
## 🚨📢 v0.140.0
|
||||
|
||||
⚠️ Major release ⚠️ - Please test thoroughly before / after upgrading.
|
||||
|
||||
#### 🚨📢 BREAKING CHANGES
|
||||
|
||||
**Middleware**
|
||||
|
@ -221,6 +223,28 @@
|
|||
{% component "profile" name="John" job="Developer" / %}
|
||||
```
|
||||
|
||||
**Slots**
|
||||
|
||||
- If you instantiated `Slot` class with kwargs, you should now use `contents` instead of `content_func`.
|
||||
|
||||
Before:
|
||||
|
||||
```py
|
||||
slot = Slot(content_func=lambda *a, **kw: "CONTENT")
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```py
|
||||
slot = Slot(contents=lambda *a, **kw: "CONTENT")
|
||||
```
|
||||
|
||||
Alternatively, pass the function / content as first positional argument:
|
||||
|
||||
```py
|
||||
slot = Slot(lambda *a, **kw: "CONTENT")
|
||||
```
|
||||
|
||||
**Miscellaneous**
|
||||
|
||||
- The second argument to `render_dependencies()` is now `strategy` instead of `type`.
|
||||
|
@ -436,6 +460,12 @@
|
|||
|
||||
Then, the `contents` attribute of the `BaseNode` instance will contain the string `"Hello, world!"`.
|
||||
|
||||
- `Slot` class now has a `Slot.contents` attribute, which contains the original contents:
|
||||
|
||||
- If `Slot` was created from `{% fill %}` tag, `Slot.contents` will contain the body of the `{% fill %}` tag.
|
||||
- If `Slot` was created from string via `Slot("...")`, `Slot.contents` will contain that string.
|
||||
- If `Slot` was created from a function, `Slot.contents` will contain that function.
|
||||
|
||||
#### 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)).
|
||||
|
|
|
@ -24,7 +24,7 @@ from weakref import ReferenceType, WeakValueDictionary, finalize
|
|||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.forms.widgets import Media as MediaCls
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.template.base import NodeList, Origin, Parser, Template, TextNode, Token
|
||||
from django.template.base import NodeList, Origin, Parser, Template, Token
|
||||
from django.template.context import Context, RequestContext
|
||||
from django.template.loader import get_template
|
||||
from django.template.loader_tags import BLOCK_CONTEXT_KEY, BlockContext
|
||||
|
@ -74,7 +74,6 @@ from django_components.slots import (
|
|||
SlotRef,
|
||||
SlotResult,
|
||||
_is_extracting_fill,
|
||||
_nodelist_to_slot_render_func,
|
||||
resolve_fills,
|
||||
)
|
||||
from django_components.template import cached_template
|
||||
|
@ -2716,14 +2715,14 @@ class Component(metaclass=ComponentMeta):
|
|||
# NOTE: `gen_escaped_content_func` is defined as a separate function, instead of being inlined within
|
||||
# the forloop, because the value the forloop variable points to changes with each loop iteration.
|
||||
def gen_escaped_content_func(content: SlotFunc, slot_name: str) -> Slot:
|
||||
# Case: Already Slot, already escaped, and names tracing names assigned, so nothing to do.
|
||||
# Case: Already Slot, already escaped, and names assigned, so nothing to do.
|
||||
if isinstance(content, Slot) and content.escaped and content.slot_name and content.component_name:
|
||||
return content
|
||||
|
||||
# Otherwise, we create a new instance of Slot, whether `content` was already Slot or not.
|
||||
# This is so that user can potentially define a single `Slot` and use it in multiple components.
|
||||
# so we can assign metadata to our internal copies.
|
||||
if not isinstance(content, Slot) or not content.escaped:
|
||||
|
||||
# We wrap the original function so we post-process it by escaping the result.
|
||||
def content_fn(ctx: Context, slot_data: Dict, slot_ref: SlotRef) -> SlotResult:
|
||||
rendered = content(ctx, slot_data, slot_ref)
|
||||
return conditional_escape(rendered) if escape_content else rendered
|
||||
|
@ -2737,12 +2736,15 @@ class Component(metaclass=ComponentMeta):
|
|||
used_component_name = content.component_name or self.name
|
||||
used_slot_name = content.slot_name or slot_name
|
||||
used_nodelist = content.nodelist
|
||||
used_contents = content.contents if content.contents is not None else content_func
|
||||
else:
|
||||
used_component_name = self.name
|
||||
used_slot_name = slot_name
|
||||
used_nodelist = None
|
||||
used_contents = content_func
|
||||
|
||||
slot = Slot(
|
||||
contents=used_contents,
|
||||
content_func=content_func,
|
||||
component_name=used_component_name,
|
||||
slot_name=used_slot_name,
|
||||
|
@ -2753,16 +2755,17 @@ class Component(metaclass=ComponentMeta):
|
|||
return slot
|
||||
|
||||
for slot_name, content in fills.items():
|
||||
# Case: No content, so nothing to do.
|
||||
if content is None:
|
||||
continue
|
||||
# Case: Content is a string / scalar
|
||||
elif not callable(content):
|
||||
slot = _nodelist_to_slot_render_func(
|
||||
component_name=self.name,
|
||||
slot_name=slot_name,
|
||||
nodelist=NodeList([TextNode(conditional_escape(content) if escape_content else content)]),
|
||||
data_var=None,
|
||||
default_var=None,
|
||||
escaped_content = conditional_escape(content) if escape_content else content
|
||||
# NOTE: `Slot.content_func` and `Slot.nodelist` are set in `Slot.__init__()`
|
||||
slot: Slot = Slot(
|
||||
contents=escaped_content, component_name=self.name, slot_name=slot_name, escaped=True
|
||||
)
|
||||
# Case: Content is a callable, so either a plain function or a `Slot` instance.
|
||||
else:
|
||||
slot = gen_escaped_content_func(content, slot_name)
|
||||
|
||||
|
@ -2943,7 +2946,7 @@ class ComponentNode(BaseNode):
|
|||
|
||||
component_cls: Type[Component] = self.registry.get(self.name)
|
||||
|
||||
slot_fills = resolve_fills(context, self.nodelist, self.name)
|
||||
slot_fills = resolve_fills(context, self, self.name)
|
||||
|
||||
component: Component = component_cls(
|
||||
registered_name=self.name,
|
||||
|
|
|
@ -13,6 +13,7 @@ from typing import (
|
|||
Optional,
|
||||
Protocol,
|
||||
Set,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
|
@ -34,17 +35,17 @@ from django_components.util.logger import trace_component_msg
|
|||
from django_components.util.misc import get_index, get_last_index, is_identifier
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django_components.component import ComponentContext
|
||||
from django_components.component import ComponentContext, ComponentNode
|
||||
|
||||
TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True)
|
||||
|
||||
DEFAULT_SLOT_KEY = "default"
|
||||
FILL_GEN_CONTEXT_KEY = "_DJANGO_COMPONENTS_GEN_FILL"
|
||||
SLOT_DATA_KWARG = "data"
|
||||
SLOT_NAME_KWARG = "name"
|
||||
SLOT_DEFAULT_KWARG = "default"
|
||||
SLOT_REQUIRED_KEYWORD = "required"
|
||||
SLOT_DEFAULT_KEYWORD = "default"
|
||||
SLOT_REQUIRED_FLAG = "required"
|
||||
SLOT_DEFAULT_FLAG = "default"
|
||||
FILL_DATA_KWARG = "data"
|
||||
FILL_DEFAULT_KWARG = "default"
|
||||
|
||||
|
||||
# Public types
|
||||
|
@ -60,7 +61,13 @@ class SlotFunc(Protocol, Generic[TSlotData]):
|
|||
class Slot(Generic[TSlotData]):
|
||||
"""This class holds the slot content function along with related metadata."""
|
||||
|
||||
content_func: SlotFunc[TSlotData]
|
||||
contents: Any
|
||||
"""
|
||||
The original value that was passed to the `Slot` constructor.
|
||||
|
||||
If the slot was defined with `{% fill %}` tag, this will be the raw string contents of the slot.
|
||||
"""
|
||||
content_func: SlotFunc[TSlotData] = cast(SlotFunc[TSlotData], None)
|
||||
escaped: bool = False
|
||||
"""Whether the slot content has been escaped."""
|
||||
|
||||
|
@ -70,17 +77,30 @@ class Slot(Generic[TSlotData]):
|
|||
slot_name: Optional[str] = None
|
||||
"""Name of the slot that originally defined or accepted this slot fill."""
|
||||
nodelist: Optional[NodeList] = None
|
||||
"""Nodelist of the slot content."""
|
||||
"""If the slot was defined with `{% fill %} tag, this will be the Nodelist of the slot content."""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Since the `Slot` instance is treated as a function, it may be passed as `contents`
|
||||
# to the `Slot()` constructor. In that case we need to unwrap to the original value
|
||||
# if `Slot()` constructor got another Slot instance.
|
||||
# NOTE: If `Slot` was passed as `contents`, we do NOT take the metadata from the inner Slot instance.
|
||||
# Instead we treat is simply as a function.
|
||||
# NOTE: Try to avoid infinite loop if `Slot.contents` points to itself.
|
||||
seen_contents = set()
|
||||
while isinstance(self.contents, Slot) and self.contents not in seen_contents:
|
||||
seen_contents.add(id(self.contents))
|
||||
self.contents = self.contents.contents
|
||||
if id(self.contents) in seen_contents:
|
||||
raise ValueError("Detected infinite loop in `Slot.contents` pointing to itself")
|
||||
|
||||
if self.content_func is None:
|
||||
self.contents, new_nodelist, self.content_func = self._resolve_contents(self.contents)
|
||||
if self.nodelist is None:
|
||||
self.nodelist = new_nodelist
|
||||
|
||||
if not callable(self.content_func):
|
||||
raise ValueError(f"Slot content must be a callable, got: {self.content_func}")
|
||||
|
||||
# Allow passing Slot instances as content functions
|
||||
if isinstance(self.content_func, Slot):
|
||||
inner_slot = self.content_func
|
||||
self.content_func = inner_slot.content_func
|
||||
|
||||
# Allow to treat the instances as functions
|
||||
def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult:
|
||||
return self.content_func(ctx, slot_data, slot_ref)
|
||||
|
@ -96,6 +116,22 @@ class Slot(Generic[TSlotData]):
|
|||
slot_name = f"'{self.slot_name}'" if self.slot_name else None
|
||||
return f"<{self.__class__.__name__} component_name={comp_name} slot_name={slot_name}>"
|
||||
|
||||
def _resolve_contents(self, contents: Any) -> Tuple[Any, NodeList, SlotFunc[TSlotData]]:
|
||||
# Case: Content is a string / scalar, so we can use `TextNode` to render it.
|
||||
if not callable(contents):
|
||||
slot = _nodelist_to_slot(
|
||||
component_name=self.component_name or "<Slot._resolve_contents>",
|
||||
slot_name=self.slot_name,
|
||||
nodelist=NodeList([TextNode(contents)]),
|
||||
contents=contents,
|
||||
data_var=None,
|
||||
default_var=None,
|
||||
)
|
||||
return slot.contents, slot.nodelist, slot.content_func
|
||||
|
||||
# Otherwise, we're dealing with a function.
|
||||
return contents, None, contents
|
||||
|
||||
|
||||
# NOTE: This must be defined here, so we don't have any forward references
|
||||
# otherwise Pydantic has problem resolving the types.
|
||||
|
@ -313,7 +349,7 @@ class SlotNode(BaseNode):
|
|||
|
||||
tag = "slot"
|
||||
end_tag = "endslot"
|
||||
allowed_flags = [SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD]
|
||||
allowed_flags = [SLOT_DEFAULT_FLAG, SLOT_REQUIRED_FLAG]
|
||||
|
||||
# NOTE:
|
||||
# In the current implementation, the slots are resolved only at the render time.
|
||||
|
@ -356,8 +392,8 @@ class SlotNode(BaseNode):
|
|||
component_path = component_ctx.component_path
|
||||
slot_fills = component_ctx.fills
|
||||
slot_name = name
|
||||
is_default = self.flags[SLOT_DEFAULT_KEYWORD]
|
||||
is_required = self.flags[SLOT_REQUIRED_KEYWORD]
|
||||
is_default = self.flags[SLOT_DEFAULT_FLAG]
|
||||
is_required = self.flags[SLOT_REQUIRED_FLAG]
|
||||
|
||||
trace_component_msg(
|
||||
"RENDER_SLOT_START",
|
||||
|
@ -515,12 +551,15 @@ class SlotNode(BaseNode):
|
|||
slot_fill = SlotFill(
|
||||
name=slot_name,
|
||||
is_filled=False,
|
||||
slot=_nodelist_to_slot_render_func(
|
||||
slot=_nodelist_to_slot(
|
||||
component_name=component_name,
|
||||
slot_name=slot_name,
|
||||
nodelist=self.nodelist,
|
||||
contents=self.contents,
|
||||
data_var=None,
|
||||
default_var=None,
|
||||
# Escaped because this was defined in the template
|
||||
escaped=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
@ -752,28 +791,28 @@ class FillNode(BaseNode):
|
|||
|
||||
if data is not None:
|
||||
if not isinstance(data, str):
|
||||
raise TemplateSyntaxError(f"Fill tag '{SLOT_DATA_KWARG}' kwarg must resolve to a string, got {data}")
|
||||
raise TemplateSyntaxError(f"Fill tag '{FILL_DATA_KWARG}' kwarg must resolve to a string, got {data}")
|
||||
if not is_identifier(data):
|
||||
raise RuntimeError(
|
||||
f"Fill tag kwarg '{SLOT_DATA_KWARG}' does not resolve to a valid Python identifier, got '{data}'"
|
||||
f"Fill tag kwarg '{FILL_DATA_KWARG}' does not resolve to a valid Python identifier, got '{data}'"
|
||||
)
|
||||
|
||||
if default is not None:
|
||||
if not isinstance(default, str):
|
||||
raise TemplateSyntaxError(
|
||||
f"Fill tag '{SLOT_DEFAULT_KWARG}' kwarg must resolve to a string, got {default}"
|
||||
f"Fill tag '{FILL_DEFAULT_KWARG}' kwarg must resolve to a string, got {default}"
|
||||
)
|
||||
if not is_identifier(default):
|
||||
raise RuntimeError(
|
||||
f"Fill tag kwarg '{SLOT_DEFAULT_KWARG}' does not resolve to a valid Python identifier,"
|
||||
f"Fill tag kwarg '{FILL_DEFAULT_KWARG}' does not resolve to a valid Python identifier,"
|
||||
f" got '{default}'"
|
||||
)
|
||||
|
||||
# data and default cannot be bound to the same variable
|
||||
if data and default and data == default:
|
||||
raise RuntimeError(
|
||||
f"Fill '{name}' received the same string for slot default ({SLOT_DEFAULT_KWARG}=...)"
|
||||
f" and slot data ({SLOT_DATA_KWARG}=...)"
|
||||
f"Fill '{name}' received the same string for slot default ({FILL_DEFAULT_KWARG}=...)"
|
||||
f" and slot data ({FILL_DATA_KWARG}=...)"
|
||||
)
|
||||
|
||||
fill_data = FillWithData(
|
||||
|
@ -873,7 +912,7 @@ class FillWithData(NamedTuple):
|
|||
|
||||
def resolve_fills(
|
||||
context: Context,
|
||||
nodelist: NodeList,
|
||||
component_node: "ComponentNode",
|
||||
component_name: str,
|
||||
) -> Dict[SlotName, Slot]:
|
||||
"""
|
||||
|
@ -924,6 +963,9 @@ def resolve_fills(
|
|||
"""
|
||||
slots: Dict[SlotName, Slot] = {}
|
||||
|
||||
nodelist = component_node.nodelist
|
||||
contents = component_node.contents
|
||||
|
||||
if not nodelist:
|
||||
return slots
|
||||
|
||||
|
@ -941,12 +983,15 @@ def resolve_fills(
|
|||
)
|
||||
|
||||
if not nodelist_is_empty:
|
||||
slots[DEFAULT_SLOT_KEY] = _nodelist_to_slot_render_func(
|
||||
slots[DEFAULT_SLOT_KEY] = _nodelist_to_slot(
|
||||
component_name=component_name,
|
||||
slot_name=None, # Will be populated later
|
||||
nodelist=nodelist,
|
||||
contents=contents,
|
||||
data_var=None,
|
||||
default_var=None,
|
||||
# Escaped because this was defined in the template
|
||||
escaped=True,
|
||||
)
|
||||
|
||||
# The content has fills
|
||||
|
@ -954,13 +999,16 @@ def resolve_fills(
|
|||
# NOTE: If slot fills are explicitly defined, we use them even if they are empty (or only whitespace).
|
||||
# This is different from the default slot, where we ignore empty content.
|
||||
for fill in maybe_fills:
|
||||
slots[fill.name] = _nodelist_to_slot_render_func(
|
||||
slots[fill.name] = _nodelist_to_slot(
|
||||
component_name=component_name,
|
||||
slot_name=fill.name,
|
||||
nodelist=fill.fill.nodelist,
|
||||
contents=fill.fill.contents,
|
||||
data_var=fill.data_var,
|
||||
default_var=fill.default_var,
|
||||
extra_context=fill.extra_context,
|
||||
# Escaped because this was defined in the template
|
||||
escaped=True,
|
||||
)
|
||||
|
||||
return slots
|
||||
|
@ -1026,12 +1074,14 @@ def _escape_slot_name(name: str) -> str:
|
|||
return escaped_name
|
||||
|
||||
|
||||
def _nodelist_to_slot_render_func(
|
||||
def _nodelist_to_slot(
|
||||
component_name: str,
|
||||
slot_name: Optional[str],
|
||||
nodelist: NodeList,
|
||||
contents: Optional[str] = None,
|
||||
data_var: Optional[str] = None,
|
||||
default_var: Optional[str] = None,
|
||||
escaped: bool = False,
|
||||
extra_context: Optional[Dict[str, Any]] = None,
|
||||
) -> Slot:
|
||||
if data_var:
|
||||
|
@ -1117,8 +1167,13 @@ def _nodelist_to_slot_render_func(
|
|||
content_func=cast(SlotFunc, render_func),
|
||||
component_name=component_name,
|
||||
slot_name=slot_name,
|
||||
escaped=False,
|
||||
escaped=escaped,
|
||||
nodelist=nodelist,
|
||||
# The `contents` param passed to this function may be `None`, because it's taken from
|
||||
# `BaseNode.contents` which is `None` for self-closing tags like `{% fill "footer" / %}`.
|
||||
# But `Slot(contents=None)` would result in `Slot.contents` being the render function.
|
||||
# So we need to special-case this.
|
||||
contents=contents if contents is not None else "",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -4,13 +4,13 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py`
|
|||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, no_type_check
|
||||
from typing import no_type_check
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.template import Context, RequestContext, Template, TemplateSyntaxError
|
||||
from django.template import Context, RequestContext, Template
|
||||
from django.template.base import TextNode
|
||||
from django.test import Client
|
||||
from django.urls import path
|
||||
|
@ -24,7 +24,6 @@ from django_components import (
|
|||
register,
|
||||
types,
|
||||
)
|
||||
from django_components.slots import SlotRef
|
||||
from django_components.urls import urlpatterns as dc_urlpatterns
|
||||
|
||||
from django_components.testing import djc_test
|
||||
|
@ -283,6 +282,38 @@ class TestComponent:
|
|||
""",
|
||||
)
|
||||
|
||||
def test_get_component_by_id(self):
|
||||
class SimpleComponent(Component):
|
||||
pass
|
||||
|
||||
assert get_component_by_class_id(SimpleComponent.class_id) == SimpleComponent
|
||||
|
||||
def test_get_component_by_id_raises_on_missing_component(self):
|
||||
with pytest.raises(KeyError):
|
||||
get_component_by_class_id("nonexistent")
|
||||
|
||||
def test_get_context_data_returns_none(self):
|
||||
class SimpleComponent(Component):
|
||||
template = "Hello"
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return None
|
||||
|
||||
assert SimpleComponent.render() == "Hello"
|
||||
|
||||
|
||||
@djc_test
|
||||
class TestComponentRenderAPI:
|
||||
def test_component_render_id(self):
|
||||
class SimpleComponent(Component):
|
||||
template = "render_id: {{ render_id }}"
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return {"render_id": self.id}
|
||||
|
||||
rendered = SimpleComponent.render()
|
||||
assert rendered == "render_id: ca1bc3e"
|
||||
|
||||
def test_input(self):
|
||||
class TestComponent(Component):
|
||||
@no_type_check
|
||||
|
@ -325,98 +356,6 @@ class TestComponent:
|
|||
""",
|
||||
)
|
||||
|
||||
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
|
||||
def test_prepends_exceptions_with_component_path(self, components_settings):
|
||||
@register("broken")
|
||||
class Broken(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div> injected: {{ data|safe }} </div>
|
||||
<main>
|
||||
{% slot "content" default / %}
|
||||
</main>
|
||||
"""
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
data = self.inject("my_provide")
|
||||
data["data1"] # This should raise TypeError
|
||||
return {"data": data}
|
||||
|
||||
@register("provider")
|
||||
class Provider(Component):
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return {"data": kwargs["data"]}
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" data=data %}
|
||||
{% slot "content" default / %}
|
||||
{% endprovide %}
|
||||
"""
|
||||
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return {"data": kwargs["data"]}
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "provider" data=data %}
|
||||
{% component "broken" %}
|
||||
{% slot "content" default / %}
|
||||
{% endcomponent %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
|
||||
@register("root")
|
||||
class Root(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "parent" data=123 %}
|
||||
{% fill "content" %}
|
||||
456
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match=re.escape(
|
||||
"An error occured while rendering components Root > parent > provider > provider(slot:content) > broken:\n" # noqa: E501
|
||||
"tuple indices must be integers or slices, not str"
|
||||
),
|
||||
):
|
||||
Root.render()
|
||||
|
||||
def test_get_component_by_id(self):
|
||||
class SimpleComponent(Component):
|
||||
pass
|
||||
|
||||
assert get_component_by_class_id(SimpleComponent.class_id) == SimpleComponent
|
||||
|
||||
def test_get_component_by_id_raises_on_missing_component(self):
|
||||
with pytest.raises(KeyError):
|
||||
get_component_by_class_id("nonexistent")
|
||||
|
||||
def test_component_render_id(self):
|
||||
class SimpleComponent(Component):
|
||||
template = "render_id: {{ render_id }}"
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return {"render_id": self.id}
|
||||
|
||||
rendered = SimpleComponent.render()
|
||||
assert rendered == "render_id: ca1bc3e"
|
||||
|
||||
def test_get_context_data_returns_none(self):
|
||||
class SimpleComponent(Component):
|
||||
template = "Hello"
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return None
|
||||
|
||||
assert SimpleComponent.render() == "Hello"
|
||||
|
||||
|
||||
@djc_test
|
||||
class TestComponentRender:
|
||||
|
@ -586,92 +525,6 @@ class TestComponentRender:
|
|||
"HELLO",
|
||||
)
|
||||
|
||||
@djc_test(
|
||||
parametrize=(
|
||||
["components_settings", "is_isolated"],
|
||||
[
|
||||
[{"context_behavior": "django"}, False],
|
||||
[{"context_behavior": "isolated"}, True],
|
||||
],
|
||||
["django", "isolated"],
|
||||
)
|
||||
)
|
||||
def test_render_slot_as_func(self, components_settings, is_isolated):
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% slot "first" required data1="abc" data2:hello="world" data2:one=123 %}
|
||||
SLOT_DEFAULT
|
||||
{% endslot %}
|
||||
"""
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return {
|
||||
"the_arg": args[0],
|
||||
"the_kwarg": kwargs.pop("the_kwarg", None),
|
||||
"kwargs": kwargs,
|
||||
}
|
||||
|
||||
def first_slot(ctx: Context, slot_data: Dict, slot_ref: SlotRef):
|
||||
assert isinstance(ctx, Context)
|
||||
# NOTE: Since the slot has access to the Context object, it should behave
|
||||
# the same way as it does in templates - when in "isolated" mode, then the
|
||||
# slot fill has access only to the "root" context, but not to the data of
|
||||
# get_template_data() of SimpleComponent.
|
||||
if is_isolated:
|
||||
assert ctx.get("the_arg") is None
|
||||
assert ctx.get("the_kwarg") is None
|
||||
assert ctx.get("kwargs") is None
|
||||
assert ctx.get("abc") is None
|
||||
else:
|
||||
assert ctx["the_arg"] == "1"
|
||||
assert ctx["the_kwarg"] == 3
|
||||
assert ctx["kwargs"] == {}
|
||||
assert ctx["abc"] == "def"
|
||||
|
||||
slot_data_expected = {
|
||||
"data1": "abc",
|
||||
"data2": {"hello": "world", "one": 123},
|
||||
}
|
||||
assert slot_data_expected == slot_data
|
||||
|
||||
assert isinstance(slot_ref, SlotRef)
|
||||
assert "SLOT_DEFAULT" == str(slot_ref).strip()
|
||||
|
||||
return f"FROM_INSIDE_FIRST_SLOT | {slot_ref}"
|
||||
|
||||
rendered = SimpleComponent.render(
|
||||
context={"abc": "def"},
|
||||
args=["1"],
|
||||
kwargs={"the_kwarg": 3},
|
||||
slots={"first": first_slot},
|
||||
)
|
||||
assertHTMLEqual(
|
||||
rendered,
|
||||
"FROM_INSIDE_FIRST_SLOT | SLOT_DEFAULT",
|
||||
)
|
||||
|
||||
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
|
||||
def test_render_raises_on_missing_slot(self, components_settings):
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% slot "first" required %}
|
||||
{% endslot %}
|
||||
"""
|
||||
|
||||
with pytest.raises(
|
||||
TemplateSyntaxError,
|
||||
match=re.escape(
|
||||
"Slot 'first' is marked as 'required' (i.e. non-optional), yet no fill is provided."
|
||||
),
|
||||
):
|
||||
SimpleComponent.render()
|
||||
|
||||
SimpleComponent.render(
|
||||
slots={"first": "FIRST_SLOT"},
|
||||
)
|
||||
|
||||
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
|
||||
def test_render_with_include(self, components_settings):
|
||||
class SimpleComponent(Component):
|
||||
|
@ -921,6 +774,69 @@ class TestComponentRender:
|
|||
"Variable: <strong data-djc-id-ca1bc3e>ca1bc3e</strong>",
|
||||
)
|
||||
|
||||
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
|
||||
def test_prepends_exceptions_with_component_path(self, components_settings):
|
||||
@register("broken")
|
||||
class Broken(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div> injected: {{ data|safe }} </div>
|
||||
<main>
|
||||
{% slot "content" default / %}
|
||||
</main>
|
||||
"""
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
data = self.inject("my_provide")
|
||||
data["data1"] # This should raise TypeError
|
||||
return {"data": data}
|
||||
|
||||
@register("provider")
|
||||
class Provider(Component):
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return {"data": kwargs["data"]}
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" data=data %}
|
||||
{% slot "content" default / %}
|
||||
{% endprovide %}
|
||||
"""
|
||||
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return {"data": kwargs["data"]}
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "provider" data=data %}
|
||||
{% component "broken" %}
|
||||
{% slot "content" default / %}
|
||||
{% endcomponent %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
|
||||
@register("root")
|
||||
class Root(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "parent" data=123 %}
|
||||
{% fill "content" %}
|
||||
456
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match=re.escape(
|
||||
"An error occured while rendering components Root > parent > provider > provider(slot:content) > broken:\n" # noqa: E501
|
||||
"tuple indices must be integers or slices, not str"
|
||||
),
|
||||
):
|
||||
Root.render()
|
||||
|
||||
|
||||
@djc_test
|
||||
class TestComponentHook:
|
||||
|
|
351
tests/test_slots.py
Normal file
351
tests/test_slots.py
Normal file
|
@ -0,0 +1,351 @@
|
|||
"""
|
||||
Tests focusing on the Python part of slots.
|
||||
For tests focusing on the `{% slot %}` tag, see `test_templatetags_slot_fill.py`
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict
|
||||
|
||||
import pytest
|
||||
from django.template import Context, Template, TemplateSyntaxError
|
||||
from django.template.base import NodeList, TextNode
|
||||
from pytest_django.asserts import assertHTMLEqual
|
||||
|
||||
from django_components import Component, register, types
|
||||
from django_components.slots import Slot, SlotRef
|
||||
|
||||
from django_components.testing import djc_test
|
||||
from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
|
||||
|
||||
setup_test_config({"autodiscover": False})
|
||||
|
||||
|
||||
# Test interaction of the `Slot` instances with Component rendering
|
||||
@djc_test
|
||||
class TestSlot:
|
||||
@djc_test(
|
||||
parametrize=(
|
||||
["components_settings", "is_isolated"],
|
||||
[
|
||||
[{"context_behavior": "django"}, False],
|
||||
[{"context_behavior": "isolated"}, True],
|
||||
],
|
||||
["django", "isolated"],
|
||||
)
|
||||
)
|
||||
def test_render_slot_as_func(self, components_settings, is_isolated):
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% slot "first" required data1="abc" data2:hello="world" data2:one=123 %}
|
||||
SLOT_DEFAULT
|
||||
{% endslot %}
|
||||
"""
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
return {
|
||||
"the_arg": args[0],
|
||||
"the_kwarg": kwargs.pop("the_kwarg", None),
|
||||
"kwargs": kwargs,
|
||||
}
|
||||
|
||||
def first_slot(ctx: Context, slot_data: Dict, slot_ref: SlotRef):
|
||||
assert isinstance(ctx, Context)
|
||||
# NOTE: Since the slot has access to the Context object, it should behave
|
||||
# the same way as it does in templates - when in "isolated" mode, then the
|
||||
# slot fill has access only to the "root" context, but not to the data of
|
||||
# get_template_data() of SimpleComponent.
|
||||
if is_isolated:
|
||||
assert ctx.get("the_arg") is None
|
||||
assert ctx.get("the_kwarg") is None
|
||||
assert ctx.get("kwargs") is None
|
||||
assert ctx.get("abc") is None
|
||||
else:
|
||||
assert ctx["the_arg"] == "1"
|
||||
assert ctx["the_kwarg"] == 3
|
||||
assert ctx["kwargs"] == {}
|
||||
assert ctx["abc"] == "def"
|
||||
|
||||
slot_data_expected = {
|
||||
"data1": "abc",
|
||||
"data2": {"hello": "world", "one": 123},
|
||||
}
|
||||
assert slot_data_expected == slot_data
|
||||
|
||||
assert isinstance(slot_ref, SlotRef)
|
||||
assert "SLOT_DEFAULT" == str(slot_ref).strip()
|
||||
|
||||
return f"FROM_INSIDE_FIRST_SLOT | {slot_ref}"
|
||||
|
||||
rendered = SimpleComponent.render(
|
||||
context={"abc": "def"},
|
||||
args=["1"],
|
||||
kwargs={"the_kwarg": 3},
|
||||
slots={"first": first_slot},
|
||||
)
|
||||
assertHTMLEqual(
|
||||
rendered,
|
||||
"FROM_INSIDE_FIRST_SLOT | SLOT_DEFAULT",
|
||||
)
|
||||
|
||||
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
|
||||
def test_render_raises_on_missing_slot(self, components_settings):
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% slot "first" required %}
|
||||
{% endslot %}
|
||||
"""
|
||||
|
||||
with pytest.raises(
|
||||
TemplateSyntaxError,
|
||||
match=re.escape(
|
||||
"Slot 'first' is marked as 'required' (i.e. non-optional), yet no fill is provided."
|
||||
),
|
||||
):
|
||||
SimpleComponent.render()
|
||||
|
||||
with pytest.raises(
|
||||
TemplateSyntaxError,
|
||||
match=re.escape(
|
||||
"Slot 'first' is marked as 'required' (i.e. non-optional), yet no fill is provided."
|
||||
),
|
||||
):
|
||||
SimpleComponent.render(
|
||||
slots={"first": None},
|
||||
)
|
||||
|
||||
SimpleComponent.render(
|
||||
slots={"first": "FIRST_SLOT"},
|
||||
)
|
||||
|
||||
# Part of the slot caching feature - test that static content slots reuse the slot function.
|
||||
# See https://github.com/django-components/django-components/issues/1164#issuecomment-2854682354
|
||||
def test_slots_reuse_functions__string(self):
|
||||
captured_slots = {}
|
||||
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% slot "first" required %}
|
||||
{% endslot %}
|
||||
"""
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
nonlocal captured_slots
|
||||
captured_slots = slots
|
||||
|
||||
SimpleComponent.render(
|
||||
slots={"first": "FIRST_SLOT"},
|
||||
)
|
||||
|
||||
first_slot_func = captured_slots["first"]
|
||||
first_nodelist: NodeList = first_slot_func.nodelist
|
||||
assert isinstance(first_slot_func, Slot)
|
||||
assert first_slot_func.content_func is not None
|
||||
assert first_slot_func.contents == "FIRST_SLOT"
|
||||
assert len(first_nodelist) == 1
|
||||
assert isinstance(first_nodelist[0], TextNode)
|
||||
assert first_nodelist[0].s == "FIRST_SLOT"
|
||||
|
||||
captured_slots = {}
|
||||
SimpleComponent.render(
|
||||
slots={"first": "FIRST_SLOT"},
|
||||
)
|
||||
|
||||
second_slot_func = captured_slots["first"]
|
||||
second_nodelist: NodeList = second_slot_func.nodelist
|
||||
assert isinstance(second_slot_func, Slot)
|
||||
assert second_slot_func.content_func is not None
|
||||
assert second_slot_func.contents == "FIRST_SLOT"
|
||||
assert len(second_nodelist) == 1
|
||||
assert isinstance(second_nodelist[0], TextNode)
|
||||
assert second_nodelist[0].s == "FIRST_SLOT"
|
||||
|
||||
assert first_slot_func.contents == second_slot_func.contents
|
||||
|
||||
# Part of the slot caching feature - test that consistent functions passed as slots
|
||||
# reuse the slot function.
|
||||
def test_slots_reuse_functions__func(self):
|
||||
captured_slots = {}
|
||||
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% slot "first" required %}
|
||||
{% endslot %}
|
||||
"""
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
nonlocal captured_slots
|
||||
captured_slots = slots
|
||||
|
||||
slot_func = lambda ctx, slot_data, slot_ref: "FROM_INSIDE_SLOT" # noqa: E731
|
||||
|
||||
SimpleComponent.render(
|
||||
slots={"first": slot_func},
|
||||
)
|
||||
|
||||
first_slot_func = captured_slots["first"]
|
||||
assert isinstance(first_slot_func, Slot)
|
||||
assert callable(first_slot_func.content_func)
|
||||
assert callable(first_slot_func.contents)
|
||||
assert first_slot_func.nodelist is None
|
||||
|
||||
captured_slots = {}
|
||||
SimpleComponent.render(
|
||||
slots={"first": slot_func},
|
||||
)
|
||||
|
||||
second_slot_func = captured_slots["first"]
|
||||
assert isinstance(second_slot_func, Slot)
|
||||
assert callable(second_slot_func.content_func)
|
||||
assert callable(second_slot_func.contents)
|
||||
assert second_slot_func.nodelist is None
|
||||
|
||||
# NOTE: Both are functions, but different, because internally we wrap the function
|
||||
# to escape the results.
|
||||
assert first_slot_func.contents is not second_slot_func.contents
|
||||
|
||||
# Part of the slot caching feature - test that `Slot` instances with identical function
|
||||
# passed as slots reuse the slot function.
|
||||
def test_slots_reuse_functions__slot(self):
|
||||
captured_slots = {}
|
||||
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% slot "first" required %}
|
||||
{% endslot %}
|
||||
"""
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
nonlocal captured_slots
|
||||
captured_slots = slots
|
||||
|
||||
slot_func = lambda ctx, slot_data, slot_ref: "FROM_INSIDE_SLOT" # noqa: E731
|
||||
|
||||
SimpleComponent.render(
|
||||
slots={"first": Slot(slot_func)},
|
||||
)
|
||||
|
||||
first_slot_func = captured_slots["first"]
|
||||
assert isinstance(first_slot_func, Slot)
|
||||
assert callable(first_slot_func.content_func)
|
||||
assert callable(first_slot_func.contents)
|
||||
assert first_slot_func.nodelist is None
|
||||
|
||||
captured_slots = {}
|
||||
SimpleComponent.render(
|
||||
slots={"first": Slot(slot_func)},
|
||||
)
|
||||
|
||||
second_slot_func = captured_slots["first"]
|
||||
assert isinstance(second_slot_func, Slot)
|
||||
assert callable(second_slot_func.content_func)
|
||||
assert callable(second_slot_func.contents)
|
||||
assert second_slot_func.nodelist is None
|
||||
|
||||
assert first_slot_func.contents == second_slot_func.contents
|
||||
|
||||
# Part of the slot caching feature - test that identical slot fill content
|
||||
# slots reuse the slot function.
|
||||
def test_slots_reuse_functions__fill_tag_default(self):
|
||||
captured_slots = {}
|
||||
|
||||
@register("test")
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% slot "first" default %}
|
||||
{% endslot %}
|
||||
"""
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
nonlocal captured_slots
|
||||
captured_slots = slots
|
||||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "test" %}
|
||||
FROM_INSIDE_DEFAULT_SLOT
|
||||
{% endcomponent %}
|
||||
"""
|
||||
template = Template(template_str)
|
||||
|
||||
template.render(Context())
|
||||
|
||||
first_slot_func = captured_slots["default"]
|
||||
first_nodelist: NodeList = first_slot_func.nodelist
|
||||
assert isinstance(first_slot_func, Slot)
|
||||
assert callable(first_slot_func.content_func)
|
||||
assert first_slot_func.contents == "\n FROM_INSIDE_DEFAULT_SLOT\n "
|
||||
assert len(first_nodelist) == 1
|
||||
assert isinstance(first_nodelist[0], TextNode)
|
||||
assert first_nodelist[0].s == "\n FROM_INSIDE_DEFAULT_SLOT\n "
|
||||
|
||||
captured_slots = {}
|
||||
template.render(Context())
|
||||
|
||||
second_slot_func = captured_slots["default"]
|
||||
second_nodelist: NodeList = second_slot_func.nodelist
|
||||
assert isinstance(second_slot_func, Slot)
|
||||
assert callable(second_slot_func.content_func)
|
||||
assert second_slot_func.contents == "\n FROM_INSIDE_DEFAULT_SLOT\n "
|
||||
assert len(second_nodelist) == 1
|
||||
assert isinstance(second_nodelist[0], TextNode)
|
||||
assert second_nodelist[0].s == "\n FROM_INSIDE_DEFAULT_SLOT\n "
|
||||
|
||||
assert first_slot_func.contents == second_slot_func.contents
|
||||
|
||||
# Part of the slot caching feature - test that identical slot fill content
|
||||
# slots reuse the slot function.
|
||||
def test_slots_reuse_functions__fill_tag_named(self):
|
||||
captured_slots = {}
|
||||
|
||||
@register("test")
|
||||
class SimpleComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% slot "first" default %}
|
||||
{% endslot %}
|
||||
"""
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
nonlocal captured_slots
|
||||
captured_slots = slots
|
||||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "test" %}
|
||||
{% fill "first" %}
|
||||
FROM_INSIDE_NAMED_SLOT
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
template = Template(template_str)
|
||||
|
||||
template.render(Context())
|
||||
|
||||
first_slot_func = captured_slots["first"]
|
||||
first_nodelist: NodeList = first_slot_func.nodelist
|
||||
assert isinstance(first_slot_func, Slot)
|
||||
assert callable(first_slot_func.content_func)
|
||||
assert first_slot_func.contents == "\n FROM_INSIDE_NAMED_SLOT\n "
|
||||
assert len(first_nodelist) == 1
|
||||
assert isinstance(first_nodelist[0], TextNode)
|
||||
assert first_nodelist[0].s == "\n FROM_INSIDE_NAMED_SLOT\n "
|
||||
|
||||
captured_slots = {}
|
||||
template.render(Context())
|
||||
|
||||
second_slot_func = captured_slots["first"]
|
||||
second_nodelist: NodeList = second_slot_func.nodelist
|
||||
assert isinstance(second_slot_func, Slot)
|
||||
assert callable(second_slot_func.content_func)
|
||||
assert second_slot_func.contents == "\n FROM_INSIDE_NAMED_SLOT\n "
|
||||
assert len(second_nodelist) == 1
|
||||
assert isinstance(second_nodelist[0], TextNode)
|
||||
assert second_nodelist[0].s == "\n FROM_INSIDE_NAMED_SLOT\n "
|
||||
|
||||
assert first_slot_func.contents == second_slot_func.contents
|
|
@ -2374,7 +2374,6 @@ class TestSlotInput:
|
|||
def get_template_data(self, args, kwargs, slots, context):
|
||||
nonlocal seen_slots
|
||||
seen_slots = slots
|
||||
return {}
|
||||
|
||||
assert seen_slots == {}
|
||||
|
||||
|
@ -2414,7 +2413,7 @@ class TestSlotInput:
|
|||
|
||||
assert seen_slots == {}
|
||||
|
||||
header_slot = Slot(lambda *a, **kw: "HEADER_SLOT")
|
||||
header_slot: Slot = Slot(lambda *a, **kw: "HEADER_SLOT")
|
||||
main_slot_str = "MAIN_SLOT"
|
||||
footer_slot_fn = lambda *a, **kw: "FOOTER_SLOT" # noqa: E731
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue