mirror of
https://github.com/django-components/django-components.git
synced 2025-08-09 16:57:59 +00:00
refactor: fix slot context behavior (#445)
This commit is contained in:
parent
f3d6337ecc
commit
3ad0dd8677
6 changed files with 287 additions and 80 deletions
|
@ -20,12 +20,7 @@ from django.views import View
|
||||||
# way the two modules depend on one another.
|
# way the two modules depend on one another.
|
||||||
from django_components.component_registry import registry # NOQA
|
from django_components.component_registry import registry # NOQA
|
||||||
from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA
|
from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA
|
||||||
from django_components.context import (
|
from django_components.context import make_isolated_context_copy, prepare_context, set_slot_component_association
|
||||||
capture_root_context,
|
|
||||||
get_root_context,
|
|
||||||
set_root_context,
|
|
||||||
set_slot_component_association,
|
|
||||||
)
|
|
||||||
from django_components.logger import logger, trace_msg
|
from django_components.logger import logger, trace_msg
|
||||||
from django_components.middleware import is_dependency_middleware_active
|
from django_components.middleware import is_dependency_middleware_active
|
||||||
from django_components.node import walk_nodelist
|
from django_components.node import walk_nodelist
|
||||||
|
@ -268,6 +263,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
||||||
# NOTE: This if/else is important to avoid nested Contexts,
|
# NOTE: This if/else is important to avoid nested Contexts,
|
||||||
# See https://github.com/EmilStenstrom/django-components/issues/414
|
# See https://github.com/EmilStenstrom/django-components/issues/414
|
||||||
context = context_data if isinstance(context_data, Context) else Context(context_data)
|
context = context_data if isinstance(context_data, Context) else Context(context_data)
|
||||||
|
prepare_context(context, outer_context=self.outer_context or Context())
|
||||||
template = self.get_template(context)
|
template = self.get_template(context)
|
||||||
|
|
||||||
# Associate the slots with this component for this context
|
# Associate the slots with this component for this context
|
||||||
|
@ -347,10 +343,6 @@ class ComponentNode(Node):
|
||||||
resolved_component_name = self.name_fexp.resolve(context)
|
resolved_component_name = self.name_fexp.resolve(context)
|
||||||
component_cls: Type[Component] = registry.get(resolved_component_name)
|
component_cls: Type[Component] = registry.get(resolved_component_name)
|
||||||
|
|
||||||
# If this is the outer-/top-most component node, then save the outer context,
|
|
||||||
# so it can be used by nested Slots.
|
|
||||||
capture_root_context(context)
|
|
||||||
|
|
||||||
# Resolve FilterExpressions and Variables that were passed as args to the
|
# Resolve FilterExpressions and Variables that were passed as args to the
|
||||||
# component, then call component's context method
|
# component, then call component's context method
|
||||||
# to get values to insert into the context
|
# to get values to insert into the context
|
||||||
|
@ -380,12 +372,7 @@ class ComponentNode(Node):
|
||||||
|
|
||||||
# Prevent outer context from leaking into the template of the component
|
# Prevent outer context from leaking into the template of the component
|
||||||
if self.isolated_context:
|
if self.isolated_context:
|
||||||
# Even if contexts are isolated, we still need to pass down the
|
context = make_isolated_context_copy(context)
|
||||||
# original context so variables in slots can be rendered using
|
|
||||||
# the original context.
|
|
||||||
root_ctx = get_root_context(context)
|
|
||||||
context = context.new()
|
|
||||||
set_root_context(context, root_ctx)
|
|
||||||
|
|
||||||
with context.update(component_context):
|
with context.update(component_context):
|
||||||
rendered_component = component.render(context)
|
rendered_component = component.render(context)
|
||||||
|
|
|
@ -5,22 +5,65 @@ pass data across components, nodes, slots, and contexts.
|
||||||
You can think of the Context as our storage system.
|
You can think of the Context as our storage system.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from copy import copy
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from django.template import Context
|
from django.template import Context
|
||||||
|
|
||||||
from django_components.logger import trace_msg
|
from django_components.logger import trace_msg
|
||||||
|
from django_components.utils import find_last_index
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django_components.slots import FillContent
|
from django_components.slots import FillContent
|
||||||
|
|
||||||
|
|
||||||
_FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
|
_FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
|
||||||
_OUTER_CONTEXT_CONTEXT_KEY = "_DJANGO_COMPONENTS_OUTER_CONTEXT"
|
_OUTER_ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_OUTER_ROOT_CTX"
|
||||||
_SLOT_COMPONENT_ASSOC_KEY = "_DJANGO_COMPONENTS_SLOT_COMP_ASSOC"
|
_SLOT_COMPONENT_ASSOC_KEY = "_DJANGO_COMPONENTS_SLOT_COMP_ASSOC"
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_context(context: Context, outer_context: Optional[Context]) -> None:
|
||||||
|
"""Initialize the internal context state."""
|
||||||
|
# This is supposed to run ALWAYS at Component.render
|
||||||
|
if outer_context is not None:
|
||||||
|
set_outer_root_context(context, outer_context)
|
||||||
|
|
||||||
|
# Initialize mapping dicts within this rendering run.
|
||||||
|
# This is shared across the whole render chain, thus we set it only once.
|
||||||
|
if _SLOT_COMPONENT_ASSOC_KEY not in context:
|
||||||
|
context[_SLOT_COMPONENT_ASSOC_KEY] = {}
|
||||||
|
if _FILLED_SLOTS_CONTENT_CONTEXT_KEY not in context:
|
||||||
|
context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = {}
|
||||||
|
|
||||||
|
# If we're inside a forloop, we need to make a disposable copy of slot -> comp
|
||||||
|
# mapping, which can be modified in the loop. We do so by copying it onto the latest
|
||||||
|
# context layer.
|
||||||
|
#
|
||||||
|
# This is necessary, because otherwise if we have a nested loop with a same
|
||||||
|
# component used recursively, the inner slot -> comp mapping would leak into the outer.
|
||||||
|
#
|
||||||
|
# NOTE: If you ever need to debug this, insert a print/debug statement into
|
||||||
|
# `django.template.defaulttags.ForNode.render` to inspect the context object
|
||||||
|
# inside the for loop.
|
||||||
|
if "forloop" in context:
|
||||||
|
context.dicts[-1][_SLOT_COMPONENT_ASSOC_KEY] = context[_SLOT_COMPONENT_ASSOC_KEY].copy()
|
||||||
|
|
||||||
|
|
||||||
|
def make_isolated_context_copy(context: Context) -> Context:
|
||||||
|
# Even if contexts are isolated, we still need to pass down the
|
||||||
|
# metadata so variables in slots can be rendered using the correct context.
|
||||||
|
root_ctx = get_outer_root_context(context)
|
||||||
|
slot_assoc = context.get(_SLOT_COMPONENT_ASSOC_KEY, {})
|
||||||
|
slot_fills = context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {})
|
||||||
|
|
||||||
|
context_copy = context.new()
|
||||||
|
context_copy[_SLOT_COMPONENT_ASSOC_KEY] = slot_assoc
|
||||||
|
context_copy[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = slot_fills
|
||||||
|
set_outer_root_context(context_copy, root_ctx)
|
||||||
|
copy_forloop_context(context, context_copy)
|
||||||
|
|
||||||
|
return context_copy
|
||||||
|
|
||||||
|
|
||||||
def get_slot_fill(context: Context, component_id: str, slot_name: str) -> Optional["FillContent"]:
|
def get_slot_fill(context: Context, component_id: str, slot_name: str) -> Optional["FillContent"]:
|
||||||
"""
|
"""
|
||||||
Use this function to obtain a slot fill from the current context.
|
Use this function to obtain a slot fill from the current context.
|
||||||
|
@ -28,8 +71,8 @@ def get_slot_fill(context: Context, component_id: str, slot_name: str) -> Option
|
||||||
See `set_slot_fill` for more details.
|
See `set_slot_fill` for more details.
|
||||||
"""
|
"""
|
||||||
trace_msg("GET", "FILL", slot_name, component_id)
|
trace_msg("GET", "FILL", slot_name, component_id)
|
||||||
slot_key = (_FILLED_SLOTS_CONTENT_CONTEXT_KEY, component_id, slot_name)
|
slot_key = f"{component_id}__{slot_name}"
|
||||||
return context.get(slot_key, None)
|
return context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY].get(slot_key, None)
|
||||||
|
|
||||||
|
|
||||||
def set_slot_fill(context: Context, component_id: str, slot_name: str, value: "FillContent") -> None:
|
def set_slot_fill(context: Context, component_id: str, slot_name: str, value: "FillContent") -> None:
|
||||||
|
@ -38,77 +81,82 @@ def set_slot_fill(context: Context, component_id: str, slot_name: str, value: "F
|
||||||
|
|
||||||
Note that we make use of the fact that Django's Context is a stack - we can push and pop
|
Note that we make use of the fact that Django's Context is a stack - we can push and pop
|
||||||
extra contexts on top others.
|
extra contexts on top others.
|
||||||
|
|
||||||
For the slot fills to be pushed/popped wth stack layer, they need to have keys defined
|
|
||||||
directly on the Context object.
|
|
||||||
"""
|
"""
|
||||||
trace_msg("SET", "FILL", slot_name, component_id)
|
trace_msg("SET", "FILL", slot_name, component_id)
|
||||||
slot_key = (_FILLED_SLOTS_CONTENT_CONTEXT_KEY, component_id, slot_name)
|
slot_key = f"{component_id}__{slot_name}"
|
||||||
context[slot_key] = value
|
context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY][slot_key] = value
|
||||||
|
|
||||||
|
|
||||||
def get_root_context(context: Context) -> Optional[Context]:
|
def get_outer_root_context(context: Context) -> Optional[Context]:
|
||||||
"""
|
"""
|
||||||
Use this function to get the root context.
|
Use this function to get the outer root context.
|
||||||
|
|
||||||
Root context is the top-most context, AKA the context that was passed to
|
See `set_outer_root_context` for more details.
|
||||||
the initial `Template.render()`.
|
|
||||||
We pass through the root context to allow configure how slot fills should be rendered.
|
|
||||||
|
|
||||||
See the `SLOT_CONTEXT_BEHAVIOR` setting.
|
|
||||||
"""
|
"""
|
||||||
return context.get(_OUTER_CONTEXT_CONTEXT_KEY)
|
return context.get(_OUTER_ROOT_CTX_CONTEXT_KEY)
|
||||||
|
|
||||||
|
|
||||||
def set_root_context(context: Context, root_ctx: Context) -> None:
|
def set_outer_root_context(context: Context, outer_ctx: Optional[Context]) -> None:
|
||||||
"""
|
"""
|
||||||
Use this function to set the root context.
|
Use this function to set the outer root context.
|
||||||
|
|
||||||
Root context is the top-most context, AKA the context that was passed to
|
When we consider a component's template, then outer context is the context
|
||||||
the initial `Template.render()`.
|
that was available just outside of the component's template (AKA it was in
|
||||||
We pass through the root context to allow configure how slot fills should be rendered.
|
the PARENT template).
|
||||||
|
|
||||||
See the `SLOT_CONTEXT_BEHAVIOR` setting.
|
Once we have the outer context, next we get the outer ROOT context. This is
|
||||||
|
the context that was available at the top level of the PARENT template.
|
||||||
|
|
||||||
|
We pass through this context to allow to configure how slot fills should be
|
||||||
|
rendered using the `SLOT_CONTEXT_BEHAVIOR` setting.
|
||||||
"""
|
"""
|
||||||
context.push({_OUTER_CONTEXT_CONTEXT_KEY: root_ctx})
|
if outer_ctx and len(outer_ctx.dicts) > 1:
|
||||||
|
outer_root_context: Context = outer_ctx.new()
|
||||||
|
# NOTE_1:
|
||||||
|
# - Index 0 are the defaults set in BaseContext
|
||||||
|
# - Index 1 is the context generated by `Component.get_context_data`
|
||||||
|
# of the parent's component
|
||||||
|
# - All later indices (2, 3, ...) are extra layers added by the rendering
|
||||||
|
# logic (each Node usually adds it's own context layer)
|
||||||
|
outer_root_context.push(outer_ctx.dicts[1])
|
||||||
|
else:
|
||||||
|
outer_root_context = Context()
|
||||||
|
|
||||||
|
# Include the mappings.
|
||||||
|
if _SLOT_COMPONENT_ASSOC_KEY in context:
|
||||||
|
outer_root_context[_SLOT_COMPONENT_ASSOC_KEY] = context[_SLOT_COMPONENT_ASSOC_KEY]
|
||||||
|
if _FILLED_SLOTS_CONTENT_CONTEXT_KEY in context:
|
||||||
|
outer_root_context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY]
|
||||||
|
|
||||||
|
context[_OUTER_ROOT_CTX_CONTEXT_KEY] = outer_root_context
|
||||||
|
|
||||||
|
|
||||||
def capture_root_context(context: Context) -> None:
|
def set_slot_component_association(
|
||||||
"""
|
context: Context,
|
||||||
Set the root context if it was not set before.
|
slot_id: str,
|
||||||
|
component_id: str,
|
||||||
Root context is the top-most context, AKA the context that was passed to
|
) -> None:
|
||||||
the initial `Template.render()`.
|
|
||||||
We pass through the root context to allow configure how slot fills should be rendered.
|
|
||||||
|
|
||||||
See the `SLOT_CONTEXT_BEHAVIOR` setting.
|
|
||||||
"""
|
|
||||||
root_ctx_already_defined = _OUTER_CONTEXT_CONTEXT_KEY in context
|
|
||||||
if not root_ctx_already_defined:
|
|
||||||
set_root_context(context, copy(context))
|
|
||||||
|
|
||||||
|
|
||||||
def set_slot_component_association(context: Context, slot_id: str, component_id: str) -> None:
|
|
||||||
"""
|
"""
|
||||||
Set association between a Slot and a Component in the current context.
|
Set association between a Slot and a Component in the current context.
|
||||||
|
|
||||||
We use SlotNodes to render slot fills. SlotNodes are created only at Template parse time.
|
We use SlotNodes to render slot fills. SlotNodes are created only at Template
|
||||||
However, when we are using components with slots in (another) template, we can render
|
parse time.
|
||||||
the same component multiple time. So we can have multiple FillNodes intended to be used
|
However, when we refer to components with slots in (another) template (using
|
||||||
with the same SlotNode.
|
`{% component %}`), we can render the same component multiple time. So we can
|
||||||
|
have multiple FillNodes intended to be used with the same SlotNode.
|
||||||
|
|
||||||
So how do we tell the SlotNode which FillNode to render? We do so by tagging the ComponentNode
|
So how do we tell the SlotNode which FillNode to render? We do so by tagging
|
||||||
and FillNodes with a unique component_id, which ties them together. And then we tell SlotNode
|
the ComponentNode and FillNodes with a unique component_id, which ties them
|
||||||
which component_id to use to be able to find the correct Component/Fill.
|
together. And then we tell SlotNode which component_id to use to be able to
|
||||||
|
find the correct Component/Fill.
|
||||||
|
|
||||||
We don't want to store this info on the Nodes themselves, as we need to treat them as
|
We don't want to store this info on the Nodes themselves, as we need to treat
|
||||||
immutable due to caching of Templates by Django.
|
them as immutable due to caching of Templates by Django.
|
||||||
|
|
||||||
Hence, we use the Context to store the associations of SlotNode <-> Component for
|
Hence, we use the Context to store the associations of SlotNode <-> Component
|
||||||
the current context stack.
|
for the current context stack.
|
||||||
"""
|
"""
|
||||||
key = (_SLOT_COMPONENT_ASSOC_KEY, slot_id)
|
context[_SLOT_COMPONENT_ASSOC_KEY][slot_id] = component_id
|
||||||
context[key] = component_id
|
|
||||||
|
|
||||||
|
|
||||||
def get_slot_component_association(context: Context, slot_id: str) -> str:
|
def get_slot_component_association(context: Context, slot_id: str) -> str:
|
||||||
|
@ -118,5 +166,17 @@ def get_slot_component_association(context: Context, slot_id: str) -> str:
|
||||||
|
|
||||||
See `set_slot_component_association` for more details.
|
See `set_slot_component_association` for more details.
|
||||||
"""
|
"""
|
||||||
key = (_SLOT_COMPONENT_ASSOC_KEY, slot_id)
|
return context[_SLOT_COMPONENT_ASSOC_KEY][slot_id]
|
||||||
return context[key]
|
|
||||||
|
|
||||||
|
def copy_forloop_context(from_context: Context, to_context: Context) -> None:
|
||||||
|
"""Forward the info about the current loop"""
|
||||||
|
# Note that the ForNode (which implements for loop behavior) does not
|
||||||
|
# only add the `forloop` key, but also keys corresponding to the loop elements
|
||||||
|
# So if the loop syntax is `{% for my_val in my_lists %}`, then ForNode also
|
||||||
|
# sets a `my_val` key.
|
||||||
|
# For this reason, instead of copying individual keys, we copy the whole stack layer
|
||||||
|
# set by ForNode.
|
||||||
|
if "forloop" in from_context:
|
||||||
|
forloop_dict_index = find_last_index(from_context.dicts, lambda d: "forloop" in d)
|
||||||
|
to_context.update(from_context.dicts[forloop_dict_index])
|
||||||
|
|
|
@ -10,7 +10,13 @@ from django.template.exceptions import TemplateSyntaxError
|
||||||
from django.utils.safestring import SafeString, mark_safe
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
|
|
||||||
from django_components.app_settings import SlotContextBehavior, app_settings
|
from django_components.app_settings import SlotContextBehavior, app_settings
|
||||||
from django_components.context import get_root_context, get_slot_component_association, get_slot_fill, set_slot_fill
|
from django_components.context import (
|
||||||
|
copy_forloop_context,
|
||||||
|
get_outer_root_context,
|
||||||
|
get_slot_component_association,
|
||||||
|
get_slot_fill,
|
||||||
|
set_slot_fill,
|
||||||
|
)
|
||||||
from django_components.logger import trace_msg
|
from django_components.logger import trace_msg
|
||||||
from django_components.node import nodelist_has_content
|
from django_components.node import nodelist_has_content
|
||||||
from django_components.utils import gen_id
|
from django_components.utils import gen_id
|
||||||
|
@ -139,14 +145,16 @@ class SlotNode(Node):
|
||||||
|
|
||||||
See SlotContextBehavior for the description of each option.
|
See SlotContextBehavior for the description of each option.
|
||||||
"""
|
"""
|
||||||
root_ctx = get_root_context(context) or Context()
|
root_ctx = get_outer_root_context(context) or Context()
|
||||||
|
|
||||||
if app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ALLOW_OVERRIDE:
|
if app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ALLOW_OVERRIDE:
|
||||||
return context
|
return context
|
||||||
elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ISOLATED:
|
elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ISOLATED:
|
||||||
return root_ctx
|
new_context: Context = copy(root_ctx)
|
||||||
|
copy_forloop_context(context, new_context)
|
||||||
|
return new_context
|
||||||
elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.PREFER_ROOT:
|
elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.PREFER_ROOT:
|
||||||
new_context: Context = copy(context)
|
new_context = copy(context)
|
||||||
new_context.update(root_ctx.flatten())
|
new_context.update(root_ctx.flatten())
|
||||||
return new_context
|
return new_context
|
||||||
else:
|
else:
|
||||||
|
@ -483,8 +491,8 @@ def _report_slot_errors(
|
||||||
for fill_name in unmatched_fills:
|
for fill_name in unmatched_fills:
|
||||||
fuzzy_slot_name_matches = difflib.get_close_matches(fill_name, unfilled_slots, n=1, cutoff=0.7)
|
fuzzy_slot_name_matches = difflib.get_close_matches(fill_name, unfilled_slots, n=1, cutoff=0.7)
|
||||||
msg = (
|
msg = (
|
||||||
f"Component '{registered_name}' passed fill "
|
f"Component '{registered_name}' passed fill that refers to undefined slot:"
|
||||||
f"that refers to undefined slot: '{fill_name}'."
|
f" '{fill_name}'."
|
||||||
f"\nUnfilled slot names are: {sorted(unfilled_slots)}."
|
f"\nUnfilled slot names are: {sorted(unfilled_slots)}."
|
||||||
)
|
)
|
||||||
if fuzzy_slot_name_matches:
|
if fuzzy_slot_name_matches:
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import glob
|
import glob
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, NamedTuple, Optional
|
from typing import Any, Callable, List, NamedTuple, Optional
|
||||||
|
|
||||||
from django.template.engine import Engine
|
from django.template.engine import Engine
|
||||||
|
|
||||||
|
@ -49,3 +49,10 @@ def gen_id(length: int = 5) -> str:
|
||||||
|
|
||||||
# Pad the ID with `0`s up to 4 digits, e.g. `0007`
|
# Pad the ID with `0`s up to 4 digits, e.g. `0007`
|
||||||
return f"{_id:04}"
|
return f"{_id:04}"
|
||||||
|
|
||||||
|
|
||||||
|
def find_last_index(lst: List, predicate: Callable[[Any], bool]) -> Any:
|
||||||
|
for r_idx, elem in enumerate(reversed(lst)):
|
||||||
|
if predicate(elem):
|
||||||
|
return len(lst) - 1 - r_idx
|
||||||
|
return -1
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import re
|
import re
|
||||||
import textwrap
|
import textwrap
|
||||||
from typing import Callable, Optional
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
from django.template import Context, Template, TemplateSyntaxError
|
from django.template import Context, Template, TemplateSyntaxError
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
# isort: off
|
# isort: off
|
||||||
from .django_test_setup import * # NOQA
|
from .django_test_setup import * # NOQA
|
||||||
|
@ -85,6 +86,34 @@ class ComponentWithDefaultAndRequiredSlot(component.Component):
|
||||||
template_name = "template_with_default_and_required_slot.html"
|
template_name = "template_with_default_and_required_slot.html"
|
||||||
|
|
||||||
|
|
||||||
|
class _ComplexChildComponent(component.Component):
|
||||||
|
template = """
|
||||||
|
{% load component_tags %}
|
||||||
|
<div>
|
||||||
|
{% slot "content" default %}
|
||||||
|
No slot!
|
||||||
|
{% endslot %}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class _ComplexParentComponent(component.Component):
|
||||||
|
template = """
|
||||||
|
{% load component_tags %}
|
||||||
|
ITEMS: {{ items }}
|
||||||
|
{% for item in items %}
|
||||||
|
<li>
|
||||||
|
{% component "complex_child" %}
|
||||||
|
{{ item.value }}
|
||||||
|
{% endcomponent %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_context_data(self, items, *args, **kwargs) -> Dict[str, Any]:
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
class ComponentTemplateTagTest(BaseTestCase):
|
class ComponentTemplateTagTest(BaseTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# NOTE: component.registry is global, so need to clear before each test
|
# NOTE: component.registry is global, so need to clear before each test
|
||||||
|
@ -1019,6 +1048,8 @@ class ComponentNestingTests(BaseTestCase):
|
||||||
super().setUpClass()
|
super().setUpClass()
|
||||||
component.registry.register("dashboard", _DashboardComponent)
|
component.registry.register("dashboard", _DashboardComponent)
|
||||||
component.registry.register("calendar", _CalendarComponent)
|
component.registry.register("calendar", _CalendarComponent)
|
||||||
|
component.registry.register("complex_child", _ComplexChildComponent)
|
||||||
|
component.registry.register("complex_parent", _ComplexParentComponent)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def tearDownClass(cls) -> None:
|
def tearDownClass(cls) -> None:
|
||||||
|
@ -1052,6 +1083,100 @@ class ComponentNestingTests(BaseTestCase):
|
||||||
"""
|
"""
|
||||||
self.assertHTMLEqual(rendered, expected)
|
self.assertHTMLEqual(rendered, expected)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
COMPONENTS={
|
||||||
|
"context_behavior": "isolated",
|
||||||
|
"slot_context_behavior": "isolated",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def test_component_nesting_slot_inside_component_fill_isolated(self):
|
||||||
|
template = Template(
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component "dashboard" %}{% endcomponent %}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rendered = template.render(Context({"items": [1, 2, 3]}))
|
||||||
|
expected = """
|
||||||
|
<div class="dashboard-component">
|
||||||
|
<div class="calendar-component">
|
||||||
|
<h1>
|
||||||
|
Welcome to your dashboard!
|
||||||
|
</h1>
|
||||||
|
<main>
|
||||||
|
Here are your to-do items for today:
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<ol>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
self.assertHTMLEqual(rendered, expected)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
COMPONENTS={
|
||||||
|
"context_behavior": "isolated",
|
||||||
|
"slot_context_behavior": "isolated",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def test_component_nesting_slot_inside_component_fill_isolated_2(self):
|
||||||
|
template = Template(
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component "dashboard" %}
|
||||||
|
{% fill "header" %}
|
||||||
|
Whoa!
|
||||||
|
{% endfill %}
|
||||||
|
{% endcomponent %}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rendered = template.render(Context({"items": [1, 2, 3]}))
|
||||||
|
expected = """
|
||||||
|
<div class="dashboard-component">
|
||||||
|
<div class="calendar-component">
|
||||||
|
<h1>
|
||||||
|
Whoa!
|
||||||
|
</h1>
|
||||||
|
<main>
|
||||||
|
Here are your to-do items for today:
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<ol>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
self.assertHTMLEqual(rendered, expected)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
COMPONENTS={
|
||||||
|
"context_behavior": "isolated",
|
||||||
|
"slot_context_behavior": "isolated",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def test_component_nesting_deep_slot_inside_component_fill_isolated(self):
|
||||||
|
|
||||||
|
template = Template(
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component "complex_parent" items=items %}{% endcomponent %}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
items = [{"value": 1}, {"value": 2}, {"value": 3}]
|
||||||
|
rendered = template.render(Context({"items": items}))
|
||||||
|
expected = """
|
||||||
|
ITEMS: [{'value': 1}, {'value': 2}, {'value': 3}]
|
||||||
|
<li>
|
||||||
|
<div> 1 </div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div> 2 </div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div> 3 </div>
|
||||||
|
</li>
|
||||||
|
"""
|
||||||
|
self.assertHTMLEqual(rendered, expected)
|
||||||
|
|
||||||
def test_component_nesting_component_with_fill_and_super(self):
|
def test_component_nesting_component_with_fill_and_super(self):
|
||||||
template = Template(
|
template = Template(
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
|
from typing import List
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from django.template import Context
|
from django.template import Context, Node
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
@ -32,3 +33,22 @@ def create_and_process_template_response(template, context=None, use_middleware=
|
||||||
else:
|
else:
|
||||||
response.render()
|
response.render()
|
||||||
return response.content.decode("utf-8")
|
return response.content.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def print_nodes(nodes: List[Node], indent=0) -> None:
|
||||||
|
"""
|
||||||
|
Render a Nodelist, inlining child nodes with extra on separate lines and with
|
||||||
|
extra indentation.
|
||||||
|
"""
|
||||||
|
for node in nodes:
|
||||||
|
child_nodes: List[Node] = []
|
||||||
|
for attr in node.child_nodelists:
|
||||||
|
attr_child_nodes = getattr(node, attr, None) or []
|
||||||
|
if attr_child_nodes:
|
||||||
|
child_nodes.extend(attr_child_nodes)
|
||||||
|
|
||||||
|
repr = str(node)
|
||||||
|
repr = "\n".join([(" " * 4 * indent) + line for line in repr.split("\n")])
|
||||||
|
print(repr)
|
||||||
|
if child_nodes:
|
||||||
|
print_nodes(child_nodes, indent=indent + 1)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue