mirror of
https://github.com/django-components/django-components.git
synced 2025-09-19 20:29:44 +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
|
## 🚨📢 v0.140.0
|
||||||
|
|
||||||
|
⚠️ Major release ⚠️ - Please test thoroughly before / after upgrading.
|
||||||
|
|
||||||
#### 🚨📢 BREAKING CHANGES
|
#### 🚨📢 BREAKING CHANGES
|
||||||
|
|
||||||
**Middleware**
|
**Middleware**
|
||||||
|
@ -221,6 +223,28 @@
|
||||||
{% component "profile" name="John" job="Developer" / %}
|
{% 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**
|
**Miscellaneous**
|
||||||
|
|
||||||
- The second argument to `render_dependencies()` is now `strategy` instead of `type`.
|
- 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!"`.
|
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
|
||||||
|
|
||||||
- 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)).
|
||||||
|
|
|
@ -24,7 +24,7 @@ from weakref import ReferenceType, WeakValueDictionary, finalize
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.forms.widgets import Media as MediaCls
|
from django.forms.widgets import Media as MediaCls
|
||||||
from django.http import HttpRequest, HttpResponse
|
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.context import Context, RequestContext
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
from django.template.loader_tags import BLOCK_CONTEXT_KEY, BlockContext
|
from django.template.loader_tags import BLOCK_CONTEXT_KEY, BlockContext
|
||||||
|
@ -74,7 +74,6 @@ from django_components.slots import (
|
||||||
SlotRef,
|
SlotRef,
|
||||||
SlotResult,
|
SlotResult,
|
||||||
_is_extracting_fill,
|
_is_extracting_fill,
|
||||||
_nodelist_to_slot_render_func,
|
|
||||||
resolve_fills,
|
resolve_fills,
|
||||||
)
|
)
|
||||||
from django_components.template import cached_template
|
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
|
# 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.
|
# 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:
|
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:
|
if isinstance(content, Slot) and content.escaped and content.slot_name and content.component_name:
|
||||||
return content
|
return content
|
||||||
|
|
||||||
# Otherwise, we create a new instance of Slot, whether `content` was already Slot or not.
|
# 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:
|
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:
|
def content_fn(ctx: Context, slot_data: Dict, slot_ref: SlotRef) -> SlotResult:
|
||||||
rendered = content(ctx, slot_data, slot_ref)
|
rendered = content(ctx, slot_data, slot_ref)
|
||||||
return conditional_escape(rendered) if escape_content else rendered
|
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_component_name = content.component_name or self.name
|
||||||
used_slot_name = content.slot_name or slot_name
|
used_slot_name = content.slot_name or slot_name
|
||||||
used_nodelist = content.nodelist
|
used_nodelist = content.nodelist
|
||||||
|
used_contents = content.contents if content.contents is not None else content_func
|
||||||
else:
|
else:
|
||||||
used_component_name = self.name
|
used_component_name = self.name
|
||||||
used_slot_name = slot_name
|
used_slot_name = slot_name
|
||||||
used_nodelist = None
|
used_nodelist = None
|
||||||
|
used_contents = content_func
|
||||||
|
|
||||||
slot = Slot(
|
slot = Slot(
|
||||||
|
contents=used_contents,
|
||||||
content_func=content_func,
|
content_func=content_func,
|
||||||
component_name=used_component_name,
|
component_name=used_component_name,
|
||||||
slot_name=used_slot_name,
|
slot_name=used_slot_name,
|
||||||
|
@ -2753,16 +2755,17 @@ class Component(metaclass=ComponentMeta):
|
||||||
return slot
|
return slot
|
||||||
|
|
||||||
for slot_name, content in fills.items():
|
for slot_name, content in fills.items():
|
||||||
|
# Case: No content, so nothing to do.
|
||||||
if content is None:
|
if content is None:
|
||||||
continue
|
continue
|
||||||
|
# Case: Content is a string / scalar
|
||||||
elif not callable(content):
|
elif not callable(content):
|
||||||
slot = _nodelist_to_slot_render_func(
|
escaped_content = conditional_escape(content) if escape_content else content
|
||||||
component_name=self.name,
|
# NOTE: `Slot.content_func` and `Slot.nodelist` are set in `Slot.__init__()`
|
||||||
slot_name=slot_name,
|
slot: Slot = Slot(
|
||||||
nodelist=NodeList([TextNode(conditional_escape(content) if escape_content else content)]),
|
contents=escaped_content, component_name=self.name, slot_name=slot_name, escaped=True
|
||||||
data_var=None,
|
|
||||||
default_var=None,
|
|
||||||
)
|
)
|
||||||
|
# Case: Content is a callable, so either a plain function or a `Slot` instance.
|
||||||
else:
|
else:
|
||||||
slot = gen_escaped_content_func(content, slot_name)
|
slot = gen_escaped_content_func(content, slot_name)
|
||||||
|
|
||||||
|
@ -2943,7 +2946,7 @@ class ComponentNode(BaseNode):
|
||||||
|
|
||||||
component_cls: Type[Component] = self.registry.get(self.name)
|
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(
|
component: Component = component_cls(
|
||||||
registered_name=self.name,
|
registered_name=self.name,
|
||||||
|
|
|
@ -13,6 +13,7 @@ from typing import (
|
||||||
Optional,
|
Optional,
|
||||||
Protocol,
|
Protocol,
|
||||||
Set,
|
Set,
|
||||||
|
Tuple,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
Union,
|
Union,
|
||||||
cast,
|
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
|
from django_components.util.misc import get_index, get_last_index, is_identifier
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django_components.component import ComponentContext
|
from django_components.component import ComponentContext, ComponentNode
|
||||||
|
|
||||||
TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True)
|
TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True)
|
||||||
|
|
||||||
DEFAULT_SLOT_KEY = "default"
|
DEFAULT_SLOT_KEY = "default"
|
||||||
FILL_GEN_CONTEXT_KEY = "_DJANGO_COMPONENTS_GEN_FILL"
|
FILL_GEN_CONTEXT_KEY = "_DJANGO_COMPONENTS_GEN_FILL"
|
||||||
SLOT_DATA_KWARG = "data"
|
|
||||||
SLOT_NAME_KWARG = "name"
|
SLOT_NAME_KWARG = "name"
|
||||||
SLOT_DEFAULT_KWARG = "default"
|
SLOT_REQUIRED_FLAG = "required"
|
||||||
SLOT_REQUIRED_KEYWORD = "required"
|
SLOT_DEFAULT_FLAG = "default"
|
||||||
SLOT_DEFAULT_KEYWORD = "default"
|
FILL_DATA_KWARG = "data"
|
||||||
|
FILL_DEFAULT_KWARG = "default"
|
||||||
|
|
||||||
|
|
||||||
# Public types
|
# Public types
|
||||||
|
@ -60,7 +61,13 @@ class SlotFunc(Protocol, Generic[TSlotData]):
|
||||||
class Slot(Generic[TSlotData]):
|
class Slot(Generic[TSlotData]):
|
||||||
"""This class holds the slot content function along with related metadata."""
|
"""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
|
escaped: bool = False
|
||||||
"""Whether the slot content has been escaped."""
|
"""Whether the slot content has been escaped."""
|
||||||
|
|
||||||
|
@ -70,17 +77,30 @@ class Slot(Generic[TSlotData]):
|
||||||
slot_name: Optional[str] = None
|
slot_name: Optional[str] = None
|
||||||
"""Name of the slot that originally defined or accepted this slot fill."""
|
"""Name of the slot that originally defined or accepted this slot fill."""
|
||||||
nodelist: Optional[NodeList] = None
|
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:
|
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):
|
if not callable(self.content_func):
|
||||||
raise ValueError(f"Slot content must be a callable, got: {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
|
# Allow to treat the instances as functions
|
||||||
def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult:
|
def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult:
|
||||||
return self.content_func(ctx, slot_data, slot_ref)
|
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
|
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}>"
|
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
|
# NOTE: This must be defined here, so we don't have any forward references
|
||||||
# otherwise Pydantic has problem resolving the types.
|
# otherwise Pydantic has problem resolving the types.
|
||||||
|
@ -313,7 +349,7 @@ class SlotNode(BaseNode):
|
||||||
|
|
||||||
tag = "slot"
|
tag = "slot"
|
||||||
end_tag = "endslot"
|
end_tag = "endslot"
|
||||||
allowed_flags = [SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD]
|
allowed_flags = [SLOT_DEFAULT_FLAG, SLOT_REQUIRED_FLAG]
|
||||||
|
|
||||||
# NOTE:
|
# NOTE:
|
||||||
# In the current implementation, the slots are resolved only at the render time.
|
# 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
|
component_path = component_ctx.component_path
|
||||||
slot_fills = component_ctx.fills
|
slot_fills = component_ctx.fills
|
||||||
slot_name = name
|
slot_name = name
|
||||||
is_default = self.flags[SLOT_DEFAULT_KEYWORD]
|
is_default = self.flags[SLOT_DEFAULT_FLAG]
|
||||||
is_required = self.flags[SLOT_REQUIRED_KEYWORD]
|
is_required = self.flags[SLOT_REQUIRED_FLAG]
|
||||||
|
|
||||||
trace_component_msg(
|
trace_component_msg(
|
||||||
"RENDER_SLOT_START",
|
"RENDER_SLOT_START",
|
||||||
|
@ -515,12 +551,15 @@ class SlotNode(BaseNode):
|
||||||
slot_fill = SlotFill(
|
slot_fill = SlotFill(
|
||||||
name=slot_name,
|
name=slot_name,
|
||||||
is_filled=False,
|
is_filled=False,
|
||||||
slot=_nodelist_to_slot_render_func(
|
slot=_nodelist_to_slot(
|
||||||
component_name=component_name,
|
component_name=component_name,
|
||||||
slot_name=slot_name,
|
slot_name=slot_name,
|
||||||
nodelist=self.nodelist,
|
nodelist=self.nodelist,
|
||||||
|
contents=self.contents,
|
||||||
data_var=None,
|
data_var=None,
|
||||||
default_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 data is not None:
|
||||||
if not isinstance(data, str):
|
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):
|
if not is_identifier(data):
|
||||||
raise RuntimeError(
|
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 default is not None:
|
||||||
if not isinstance(default, str):
|
if not isinstance(default, str):
|
||||||
raise TemplateSyntaxError(
|
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):
|
if not is_identifier(default):
|
||||||
raise RuntimeError(
|
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}'"
|
f" got '{default}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# data and default cannot be bound to the same variable
|
# data and default cannot be bound to the same variable
|
||||||
if data and default and data == default:
|
if data and default and data == default:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Fill '{name}' received the same string for slot default ({SLOT_DEFAULT_KWARG}=...)"
|
f"Fill '{name}' received the same string for slot default ({FILL_DEFAULT_KWARG}=...)"
|
||||||
f" and slot data ({SLOT_DATA_KWARG}=...)"
|
f" and slot data ({FILL_DATA_KWARG}=...)"
|
||||||
)
|
)
|
||||||
|
|
||||||
fill_data = FillWithData(
|
fill_data = FillWithData(
|
||||||
|
@ -873,7 +912,7 @@ class FillWithData(NamedTuple):
|
||||||
|
|
||||||
def resolve_fills(
|
def resolve_fills(
|
||||||
context: Context,
|
context: Context,
|
||||||
nodelist: NodeList,
|
component_node: "ComponentNode",
|
||||||
component_name: str,
|
component_name: str,
|
||||||
) -> Dict[SlotName, Slot]:
|
) -> Dict[SlotName, Slot]:
|
||||||
"""
|
"""
|
||||||
|
@ -924,6 +963,9 @@ def resolve_fills(
|
||||||
"""
|
"""
|
||||||
slots: Dict[SlotName, Slot] = {}
|
slots: Dict[SlotName, Slot] = {}
|
||||||
|
|
||||||
|
nodelist = component_node.nodelist
|
||||||
|
contents = component_node.contents
|
||||||
|
|
||||||
if not nodelist:
|
if not nodelist:
|
||||||
return slots
|
return slots
|
||||||
|
|
||||||
|
@ -941,12 +983,15 @@ def resolve_fills(
|
||||||
)
|
)
|
||||||
|
|
||||||
if not nodelist_is_empty:
|
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,
|
component_name=component_name,
|
||||||
slot_name=None, # Will be populated later
|
slot_name=None, # Will be populated later
|
||||||
nodelist=nodelist,
|
nodelist=nodelist,
|
||||||
|
contents=contents,
|
||||||
data_var=None,
|
data_var=None,
|
||||||
default_var=None,
|
default_var=None,
|
||||||
|
# Escaped because this was defined in the template
|
||||||
|
escaped=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# The content has fills
|
# 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).
|
# 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.
|
# This is different from the default slot, where we ignore empty content.
|
||||||
for fill in maybe_fills:
|
for fill in maybe_fills:
|
||||||
slots[fill.name] = _nodelist_to_slot_render_func(
|
slots[fill.name] = _nodelist_to_slot(
|
||||||
component_name=component_name,
|
component_name=component_name,
|
||||||
slot_name=fill.name,
|
slot_name=fill.name,
|
||||||
nodelist=fill.fill.nodelist,
|
nodelist=fill.fill.nodelist,
|
||||||
|
contents=fill.fill.contents,
|
||||||
data_var=fill.data_var,
|
data_var=fill.data_var,
|
||||||
default_var=fill.default_var,
|
default_var=fill.default_var,
|
||||||
extra_context=fill.extra_context,
|
extra_context=fill.extra_context,
|
||||||
|
# Escaped because this was defined in the template
|
||||||
|
escaped=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return slots
|
return slots
|
||||||
|
@ -1026,12 +1074,14 @@ def _escape_slot_name(name: str) -> str:
|
||||||
return escaped_name
|
return escaped_name
|
||||||
|
|
||||||
|
|
||||||
def _nodelist_to_slot_render_func(
|
def _nodelist_to_slot(
|
||||||
component_name: str,
|
component_name: str,
|
||||||
slot_name: Optional[str],
|
slot_name: Optional[str],
|
||||||
nodelist: NodeList,
|
nodelist: NodeList,
|
||||||
|
contents: Optional[str] = None,
|
||||||
data_var: Optional[str] = None,
|
data_var: Optional[str] = None,
|
||||||
default_var: Optional[str] = None,
|
default_var: Optional[str] = None,
|
||||||
|
escaped: bool = False,
|
||||||
extra_context: Optional[Dict[str, Any]] = None,
|
extra_context: Optional[Dict[str, Any]] = None,
|
||||||
) -> Slot:
|
) -> Slot:
|
||||||
if data_var:
|
if data_var:
|
||||||
|
@ -1117,8 +1167,13 @@ def _nodelist_to_slot_render_func(
|
||||||
content_func=cast(SlotFunc, render_func),
|
content_func=cast(SlotFunc, render_func),
|
||||||
component_name=component_name,
|
component_name=component_name,
|
||||||
slot_name=slot_name,
|
slot_name=slot_name,
|
||||||
escaped=False,
|
escaped=escaped,
|
||||||
nodelist=nodelist,
|
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
|
import re
|
||||||
from typing import Dict, no_type_check
|
from typing import no_type_check
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.http import HttpRequest, HttpResponse
|
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.template.base import TextNode
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
@ -24,7 +24,6 @@ from django_components import (
|
||||||
register,
|
register,
|
||||||
types,
|
types,
|
||||||
)
|
)
|
||||||
from django_components.slots import SlotRef
|
|
||||||
from django_components.urls import urlpatterns as dc_urlpatterns
|
from django_components.urls import urlpatterns as dc_urlpatterns
|
||||||
|
|
||||||
from django_components.testing import djc_test
|
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):
|
def test_input(self):
|
||||||
class TestComponent(Component):
|
class TestComponent(Component):
|
||||||
@no_type_check
|
@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
|
@djc_test
|
||||||
class TestComponentRender:
|
class TestComponentRender:
|
||||||
|
@ -586,92 +525,6 @@ class TestComponentRender:
|
||||||
"HELLO",
|
"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)
|
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
|
||||||
def test_render_with_include(self, components_settings):
|
def test_render_with_include(self, components_settings):
|
||||||
class SimpleComponent(Component):
|
class SimpleComponent(Component):
|
||||||
|
@ -921,6 +774,69 @@ class TestComponentRender:
|
||||||
"Variable: <strong data-djc-id-ca1bc3e>ca1bc3e</strong>",
|
"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
|
@djc_test
|
||||||
class TestComponentHook:
|
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):
|
def get_template_data(self, args, kwargs, slots, context):
|
||||||
nonlocal seen_slots
|
nonlocal seen_slots
|
||||||
seen_slots = slots
|
seen_slots = slots
|
||||||
return {}
|
|
||||||
|
|
||||||
assert seen_slots == {}
|
assert seen_slots == {}
|
||||||
|
|
||||||
|
@ -2414,7 +2413,7 @@ class TestSlotInput:
|
||||||
|
|
||||||
assert seen_slots == {}
|
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"
|
main_slot_str = "MAIN_SLOT"
|
||||||
footer_slot_fn = lambda *a, **kw: "FOOTER_SLOT" # noqa: E731
|
footer_slot_fn = lambda *a, **kw: "FOOTER_SLOT" # noqa: E731
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue