feat: Pass Slots to {% fill %} with 'body' kwarg (#1203)

This commit is contained in:
Juro Oravec 2025-05-22 08:01:21 +02:00 committed by GitHub
parent f069255b64
commit d514694788
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 178 additions and 19 deletions

View file

@ -574,6 +574,30 @@
- 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.
- `{% fill %}` tag now accepts `body` kwarg to pass a Slot instance to fill.
First pass a [`Slot`](../api#django_components.Slot) instance to the template
with the [`get_template_data()`](../api#django_components.Component.get_template_data)
method:
```python
from django_components import component, Slot
class Table(Component):
def get_template_data(self, args, kwargs, slots, context):
return {
"my_slot": Slot(lambda ctx: "Hello, world!"),
}
```
Then pass the slot to the `{% fill %}` tag:
```django
{% component "table" %}
{% fill "pagination" body=my_slot / %}
{% endcomponent %}
```
- Component caching can now take slots into account, by setting `Component.Cache.include_slots` to `True`.
```py

View file

@ -47,6 +47,7 @@ SLOT_REQUIRED_FLAG = "required"
SLOT_DEFAULT_FLAG = "default"
FILL_DATA_KWARG = "data"
FILL_FALLBACK_KWARG = "fallback"
FILL_BODY_KWARG = "body"
# Public types
@ -954,6 +955,46 @@ class FillNode(BaseNode):
{% endfill %}
{% endcomponent %}
```
### Passing slot fill from Python
You can pass a slot fill from Python to a component by setting the `body` kwarg
on the `{% fill %}` tag.
First pass a [`Slot`](../api#django_components.Slot) instance to the template
with the [`get_template_data()`](../api#django_components.Component.get_template_data)
method:
```python
from django_components import component, Slot
class Table(Component):
def get_template_data(self, args, kwargs, slots, context):
return {
"my_slot": Slot(lambda ctx: "Hello, world!"),
}
```
Then pass the slot to the `{% fill %}` tag:
```django
{% component "table" %}
{% fill "pagination" body=my_slot / %}
{% endcomponent %}
```
!!! warning
If you define both the `body` kwarg and the `{% fill %}` tag's body,
an error will be raised.
```django
{% component "table" %}
{% fill "pagination" body=my_slot %}
...
{% endfill %}
{% endcomponent %}
```
"""
tag = "fill"
@ -967,6 +1008,7 @@ class FillNode(BaseNode):
*,
data: Optional[str] = None,
fallback: Optional[str] = None,
body: Optional[SlotInput] = None,
# TODO_V1: Use `fallback` kwarg instead of `default`
default: Optional[str] = None,
) -> str:
@ -1015,12 +1057,19 @@ class FillNode(BaseNode):
f" and slot data ({FILL_DATA_KWARG}=...)"
)
if body is not None and self.contents:
raise TemplateSyntaxError(
f"Fill '{name}' received content both through '{FILL_BODY_KWARG}' kwarg and '{{% fill %}}' body. "
f"Use only one method."
)
fill_data = FillWithData(
fill=self,
name=name,
fallback_var=fallback,
data_var=data,
extra_context={},
body=body,
)
self._extract_fill(context, fill_data)
@ -1036,10 +1085,13 @@ class FillNode(BaseNode):
# ...
# {% endfill %}
# {% endfor %}
collected_fills: List[FillWithData] = context.get(FILL_GEN_CONTEXT_KEY, None)
collected_fills: Optional[List[FillWithData]] = context.get(FILL_GEN_CONTEXT_KEY, None)
if collected_fills is None:
return
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."
)
# To allow using variables which were defined within the template and to which
# the `{% fill %}` tag has access, we need to capture those variables too.
@ -1107,6 +1159,17 @@ class FillWithData(NamedTuple):
fill: FillNode
name: str
"""Name of the slot to be filled, as set on the `{% fill %}` tag."""
body: Optional[SlotInput]
"""
Slot fill as set by the `body` kwarg on the `{% fill %}` tag.
E.g.
```django
{% component "mycomponent" %}
{% fill "footer" body=my_slot / %}
{% endcomponent %}
```
"""
fallback_var: Optional[str]
"""Name of the FALLBACK variable, as set on the `{% fill %}` tag."""
data_var: Optional[str]
@ -1224,17 +1287,23 @@ 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(
component_name=component_name,
slot_name=fill.name,
nodelist=fill.fill.nodelist,
contents=fill.fill.contents,
data_var=fill.data_var,
fallback_var=fill.fallback_var,
extra_context=fill.extra_context,
# Escaped because this was defined in the template
escaped=True,
)
# Case: Slot fill was explicitly defined as `{% fill body=... / %}`
if fill.body is not None:
slot_fill = fill.body if isinstance(fill.body, Slot) else Slot(fill.body)
# Case: Slot fill was defined as the body of `{% fill / %}...{% endfill %}`
else:
slot_fill = _nodelist_to_slot(
component_name=component_name,
slot_name=fill.name,
nodelist=fill.fill.nodelist,
contents=fill.fill.contents,
data_var=fill.data_var,
fallback_var=fill.fallback_var,
extra_context=fill.extra_context,
# Escaped because this was defined in the template
escaped=True,
)
slots[fill.name] = slot_fill
return slots

View file

@ -99,17 +99,13 @@ class TestSlot:
with pytest.raises(
TemplateSyntaxError,
match=re.escape(
"Slot 'first' is marked as 'required' (i.e. non-optional), yet no fill is provided."
),
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."
),
match=re.escape("Slot 'first' is marked as 'required' (i.e. non-optional), yet no fill is provided."),
):
SimpleComponent.render(
slots={"first": None},
@ -408,3 +404,73 @@ class TestSlot:
assert second_nodelist[0].s == "\n FROM_INSIDE_NAMED_SLOT\n "
assert first_slot_func.contents == second_slot_func.contents
def test_pass_body_to_fill__slot(self):
@register("test")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% slot "first" default %}
{% endslot %}
"""
template_str: types.django_html = """
{% load component_tags %}
{% component "test" %}
{% fill "first" body=my_slot / %}
{% endcomponent %}
"""
template = Template(template_str)
my_slot: Slot = Slot(lambda ctx: "FROM_INSIDE_NAMED_SLOT")
rendered: str = template.render(Context({"my_slot": my_slot}))
assert rendered.strip() == "FROM_INSIDE_NAMED_SLOT"
def test_pass_body_to_fill__string(self):
@register("test")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% slot "first" default %}
{% endslot %}
"""
template_str: types.django_html = """
{% load component_tags %}
{% component "test" %}
{% fill "first" body=my_slot / %}
{% endcomponent %}
"""
template = Template(template_str)
rendered: str = template.render(Context({"my_slot": "FROM_INSIDE_NAMED_SLOT"}))
assert rendered.strip() == "FROM_INSIDE_NAMED_SLOT"
def test_pass_body_to_fill_raises_on_body(self):
@register("test")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% slot "first" default %}
{% endslot %}
"""
template_str: types.django_html = """
{% load component_tags %}
{% component "test" %}
{% fill "first" body=my_slot %}
FROM_INSIDE_NAMED_SLOT
{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
my_slot: Slot = Slot(lambda ctx: "FROM_INSIDE_NAMED_SLOT")
with pytest.raises(
TemplateSyntaxError,
match=re.escape("Fill 'first' received content both through 'body' kwarg and '{% fill %}' body."),
):
template.render(Context({"my_slot": my_slot}))