refactor: fix for nested slots (#698) (#699)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2024-10-10 14:37:21 +02:00 committed by GitHub
parent c0013c0fe4
commit ff70be35e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 198 additions and 47 deletions

View file

@ -40,7 +40,6 @@ from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as registry_ from django_components.component_registry import registry as registry_
from django_components.context import ( from django_components.context import (
_FILLED_SLOTS_CONTENT_CONTEXT_KEY, _FILLED_SLOTS_CONTENT_CONTEXT_KEY,
_PARENT_COMP_CONTEXT_KEY,
_REGISTRY_CONTEXT_KEY, _REGISTRY_CONTEXT_KEY,
_ROOT_CTX_CONTEXT_KEY, _ROOT_CTX_CONTEXT_KEY,
get_injected_context_var, get_injected_context_var,
@ -630,16 +629,10 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
else: else:
fill_content = self.fill_content fill_content = self.fill_content
# If this is top-level component and it has no parent, use outer context instead
slot_context_data = context_data
if not context[_PARENT_COMP_CONTEXT_KEY]:
slot_context_data = self.outer_context.flatten()
_, resolved_fills = resolve_slots( _, resolved_fills = resolve_slots(
context, context,
template, template,
component_name=self.name, component_name=self.name,
context_data=slot_context_data,
fill_content=fill_content, fill_content=fill_content,
# Dynamic component has a special mark do it doesn't raise certain errors # Dynamic component has a special mark do it doesn't raise certain errors
is_dynamic_component=getattr(self, "_is_dynamic_component", False), is_dynamic_component=getattr(self, "_is_dynamic_component", False),

View file

@ -15,7 +15,6 @@ from django_components.utils import find_last_index
_FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS" _FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
_ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_ROOT_CTX" _ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_ROOT_CTX"
_REGISTRY_CONTEXT_KEY = "_DJANGO_COMPONENTS_REGISTRY" _REGISTRY_CONTEXT_KEY = "_DJANGO_COMPONENTS_REGISTRY"
_PARENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_PARENT_COMP"
_CURRENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_CURRENT_COMP" _CURRENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_CURRENT_COMP"
_INJECT_CONTEXT_KEY_PREFIX = "_DJANGO_COMPONENTS_INJECT__" _INJECT_CONTEXT_KEY_PREFIX = "_DJANGO_COMPONENTS_INJECT__"
@ -57,9 +56,6 @@ def set_component_id(context: Context, component_id: str) -> None:
We use the Context object to pass down info on inside of which component We use the Context object to pass down info on inside of which component
we are currently rendering. we are currently rendering.
""" """
# Store the previous component so we can detect if the current component
# is the top-most or not. If it is, then "_parent_component_id" is None
context[_PARENT_COMP_CONTEXT_KEY] = context.get(_CURRENT_COMP_CONTEXT_KEY, None)
context[_CURRENT_COMP_CONTEXT_KEY] = component_id context[_CURRENT_COMP_CONTEXT_KEY] = component_id

View file

@ -124,7 +124,6 @@ class SlotFill(Generic[TSlotData]):
escaped_name: str escaped_name: str
is_filled: bool is_filled: bool
content_func: SlotFunc[TSlotData] content_func: SlotFunc[TSlotData]
context_data: Mapping
slot_default_var: Optional[SlotDefaultName] slot_default_var: Optional[SlotDefaultName]
slot_data_var: Optional[SlotDataName] slot_data_var: Optional[SlotDataName]
@ -479,7 +478,6 @@ def resolve_slots(
context: Context, context: Context,
template: Template, template: Template,
component_name: Optional[str], component_name: Optional[str],
context_data: Mapping[str, Any],
fill_content: Dict[SlotName, FillContent], fill_content: Dict[SlotName, FillContent],
is_dynamic_component: bool = False, is_dynamic_component: bool = False,
) -> Tuple[Dict[SlotId, Slot], Dict[SlotId, SlotFill]]: ) -> Tuple[Dict[SlotId, Slot], Dict[SlotId, SlotFill]]:
@ -497,7 +495,6 @@ def resolve_slots(
escaped_name=_escape_slot_name(name), escaped_name=_escape_slot_name(name),
is_filled=True, is_filled=True,
content_func=fill.content_func, content_func=fill.content_func,
context_data=context_data,
slot_default_var=fill.slot_default_var, slot_default_var=fill.slot_default_var,
slot_data_var=fill.slot_data_var, slot_data_var=fill.slot_data_var,
) )
@ -507,6 +504,7 @@ def resolve_slots(
slots: Dict[SlotId, Slot] = {} slots: Dict[SlotId, Slot] = {}
# This holds info on which slot (key) has which slots nested in it (value list) # This holds info on which slot (key) has which slots nested in it (value list)
slot_children: Dict[SlotId, List[SlotId]] = {} slot_children: Dict[SlotId, List[SlotId]] = {}
all_nested_slots: Set[SlotId] = set()
def on_node(entry: NodeTraverse) -> None: def on_node(entry: NodeTraverse) -> None:
node = entry.node node = entry.node
@ -535,16 +533,17 @@ def resolve_slots(
# - 0002: [] # - 0002: []
# - 0003: [0004] # - 0003: [0004]
# In other words, the data tells us that slot ID 0001 is PARENT of slot 0002. # In other words, the data tells us that slot ID 0001 is PARENT of slot 0002.
curr_entry = entry.parent parent_slot_entry = entry.parent
while curr_entry and curr_entry.parent is not None: while parent_slot_entry is not None:
if not isinstance(curr_entry.node, SlotNode): if not isinstance(parent_slot_entry.node, SlotNode):
curr_entry = curr_entry.parent parent_slot_entry = parent_slot_entry.parent
continue continue
parent_slot_id = curr_entry.node.node_id parent_slot_id = parent_slot_entry.node.node_id
if parent_slot_id not in slot_children: if parent_slot_id not in slot_children:
slot_children[parent_slot_id] = [] slot_children[parent_slot_id] = []
slot_children[parent_slot_id].append(node.node_id) slot_children[parent_slot_id].append(node.node_id)
all_nested_slots.add(node.node_id)
break break
walk_nodelist(template.nodelist, on_node, context) walk_nodelist(template.nodelist, on_node, context)
@ -565,10 +564,7 @@ def resolve_slots(
_report_slot_errors(slots, slot_fills, component_name) _report_slot_errors(slots, slot_fills, component_name)
# 5. Find roots of the slot relationships # 5. Find roots of the slot relationships
top_level_slot_ids: List[SlotId] = [] top_level_slot_ids: List[SlotId] = [node_id for node_id in slots.keys() if node_id not in all_nested_slots]
for node_id, slot in slots.items():
if node_id not in slot_children or not slot_children[node_id]:
top_level_slot_ids.append(node_id)
# 6. Walk from out-most slots inwards, and decide whether and how # 6. Walk from out-most slots inwards, and decide whether and how
# we will render each slot. # we will render each slot.
@ -592,7 +588,6 @@ def resolve_slots(
escaped_name=_escape_slot_name(slot.name), escaped_name=_escape_slot_name(slot.name),
is_filled=False, is_filled=False,
content_func=_nodelist_to_slot_render_func(slot.nodelist), content_func=_nodelist_to_slot_render_func(slot.nodelist),
context_data=context_data,
slot_default_var=None, slot_default_var=None,
slot_data_var=None, slot_data_var=None,
) )
@ -625,29 +620,30 @@ def _resolve_default_slot(
# Check for errors # Check for errors
for slot in slots.values(): for slot in slots.values():
if slot.is_default: if not slot.is_default:
if default_slot_encountered: continue
raise TemplateSyntaxError(
"Only one component slot may be marked as 'default'. "
f"To fix, check template '{template_name}' "
f"of component '{component_name}'."
)
default_slot_encountered = True
# Here we've identified which slot the default/implicit fill belongs to if default_slot_encountered:
if default_fill: raise TemplateSyntaxError(
# NOTE: We recreate new instance, passing all fields, instead of using "Only one component slot may be marked as 'default'. "
# `NamedTuple._replace`, because `_replace` is not typed. f"To fix, check template '{template_name}' "
named_fills[slot.name] = SlotFill( f"of component '{component_name}'."
is_filled=default_fill.is_filled, )
content_func=default_fill.content_func, default_slot_encountered = True
context_data=default_fill.context_data,
slot_default_var=default_fill.slot_default_var, # Here we've identified which slot the default/implicit fill belongs to
slot_data_var=default_fill.slot_data_var, if default_fill:
# Updated fields # NOTE: We recreate new instance, passing all fields, instead of using
name=slot.name, # `NamedTuple._replace`, because `_replace` is not typed.
escaped_name=_escape_slot_name(slot.name), named_fills[slot.name] = SlotFill(
) is_filled=default_fill.is_filled,
content_func=default_fill.content_func,
slot_default_var=default_fill.slot_default_var,
slot_data_var=default_fill.slot_data_var,
# Updated fields
name=slot.name,
escaped_name=_escape_slot_name(slot.name),
)
# Check: Only component templates that include a 'default' slot # Check: Only component templates that include a 'default' slot
# can be invoked with implicit filling. # can be invoked with implicit filling.
@ -725,7 +721,7 @@ def _escape_slot_name(name: str) -> str:
def _nodelist_to_slot_render_func(nodelist: NodeList) -> SlotFunc: def _nodelist_to_slot_render_func(nodelist: NodeList) -> SlotFunc:
def render_func(ctx: Context, kwargs: Dict[str, Any], slot_ref: SlotRef) -> SlotResult: def render_func(ctx: Context, slot_data: Dict[str, Any], slot_ref: SlotRef) -> SlotResult:
return nodelist.render(ctx) return nodelist.render(ctx)
return render_func # type: ignore[return-value] return render_func # type: ignore[return-value]

View file

@ -522,6 +522,172 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
) )
# See https://github.com/EmilStenstrom/django-components/issues/698
class NestedSlotsTests(BaseTestCase):
class NestedSlots(Component):
template: types.django_html = """
{% load component_tags %}
{% slot 'wrapper' %}
<div>
Wrapper Default
{% slot 'parent1' %}
<div>
Parent1 Default
{% slot 'child1' %}
<div>
Child 1 Default
</div>
{% endslot %}
</div>
{% endslot %}
{% slot 'parent2' %}
<div>
Parent2 Default
</div>
{% endslot %}
</div>
{% endslot %}
"""
def setUp(self) -> None:
super().setUp()
registry.register("example", self.NestedSlots)
@parametrize_context_behavior(["django", "isolated"])
def test_empty(self):
template_str: types.django_html = """
{% load component_tags %}
{% component 'example' %}
{% endcomponent %}
"""
rendered = Template(template_str).render(Context())
expected = """
<div>
Wrapper Default
<div>
Parent1 Default
<div>
Child 1 Default
</div>
</div>
<div>
Parent2 Default
</div>
</div>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_override_outer(self):
template_str: types.django_html = """
{% load component_tags %}
{% component 'example' %}
{% fill 'wrapper' %}
<div>
Entire Wrapper Replaced
</div>
{% endfill %}
{% endcomponent %}
"""
rendered = Template(template_str).render(Context())
expected = """
<div>
Entire Wrapper Replaced
</div>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_override_middle(self):
template_str: types.django_html = """
{% load component_tags %}
{% component 'example' %}
{% fill 'parent1' %}
<div>
Parent1 Replaced
</div>
{% endfill %}
{% endcomponent %}
"""
rendered = Template(template_str).render(Context())
expected = """
<div>
Wrapper Default
<div>
Parent1 Replaced
</div>
<div>
Parent2 Default
</div>
</div>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_override_inner(self):
template_str: types.django_html = """
{% load component_tags %}
{% component 'example' %}
{% fill 'child1' %}
<div>
Child1 Replaced
</div>
{% endfill %}
{% endcomponent %}
"""
rendered = Template(template_str).render(Context())
expected = """
<div>
Wrapper Default
<div>
Parent1 Default
<div>
Child1 Replaced
</div>
</div>
<div>
Parent2 Default
</div>
</div>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_override_all(self):
template_str: types.django_html = """
{% load component_tags %}
{% component 'example' %}
{% fill 'child1' %}
<div>
Child1 Replaced
</div>
{% endfill %}
{% fill 'parent1' %}
<div>
Parent1 Replaced
</div>
{% endfill %}
{% fill 'wrapper' %}
<div>
Entire Wrapper Replaced
</div>
{% endfill %}
{% endcomponent %}
"""
rendered = Template(template_str).render(Context())
expected = """
<div>
Entire Wrapper Replaced
</div>
"""
self.assertHTMLEqual(rendered, expected)
class SlottedTemplateRegressionTests(BaseTestCase): class SlottedTemplateRegressionTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_slotted_template_that_uses_missing_variable(self): def test_slotted_template_that_uses_missing_variable(self):