diff --git a/README.md b/README.md
index de74f649..9c940b84 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,8 @@ Read on to learn about the details!
## Release notes
+**Version 0.67** CHANGED the default way how context variables are resolved in slots. See the [documentation](#isolate-components-slots) for more details.
+
🚨📢 **Version 0.5** CHANGES THE SYNTAX for components. `component_block` is now `component`, and `component` blocks need an ending `endcomponent` tag. The new `python manage.py upgradecomponent` command can be used to upgrade a directory (use --path argument to point to each dir) of components to the new syntax automatically.
This change is done to simplify the API in anticipation of a 1.0 release of django_components. After 1.0 we intend to be stricter with big changes like this in point releases.
@@ -704,6 +706,27 @@ COMPONENTS = {
}
```
+### Isolate components' slots
+
+What variables should be available from inside a component slot?
+
+By default, variables inside component slots are preferentially taken from the root context.
+This is similar to [how Vue renders slots](https://vuejs.org/guide/components/slots.html#render-scope),
+except that, if variable is not found in the root, then the surrounding context is searched too.
+
+You can change this with the `slot_contet_behavior` setting. Options are:
+- `"prefer_root"` - Default - as described above
+- `"isolated"` - Same behavior as Vue - variables are taken ONLY from the root context
+- `"allow_override"` - slot context variables are taken from its surroundings (default before v0.67)
+
+```python
+COMPONENTS = {
+ "slot_context_behavior": "isolated",
+}
+```
+
+For further details and examples, see [SlotContextBehavior](https://github.com/EmilStenstrom/django-components/blob/master/src/django_components/app_settings.py#L12).
+
## Logging and debugging
Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). This can help with troubleshooting.
diff --git a/sampleproject/sampleproject/settings.py b/sampleproject/sampleproject/settings.py
index 57bbde96..63cb37fa 100644
--- a/sampleproject/sampleproject/settings.py
+++ b/sampleproject/sampleproject/settings.py
@@ -80,6 +80,14 @@
WSGI_APPLICATION = "sampleproject.wsgi.application"
+# COMPONENTS = {
+# "autodiscover": True,
+# "libraries": [],
+# "template_cache_size": 128,
+# "context_behavior": "isolated", # "global" | "isolated"
+# "slot_context_behavior": "prefer_root", # "allow_override" | "prefer_root" | "isolated"
+# }
+
# Database
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
diff --git a/src/django_components/app_settings.py b/src/django_components/app_settings.py
index d643fd8b..ba4db478 100644
--- a/src/django_components/app_settings.py
+++ b/src/django_components/app_settings.py
@@ -1,5 +1,5 @@
from enum import Enum
-from typing import List
+from typing import Dict, List
from django.conf import settings
@@ -9,25 +9,118 @@ class ContextBehavior(str, Enum):
ISOLATED = "isolated"
+class SlotContextBehavior(str, Enum):
+ ALLOW_OVERRIDE = "allow_override"
+ """
+ Components CAN override the slot context variables passed from the outer scopes.
+ Contexts of deeper components take precedence over shallower ones.
+
+ Example:
+
+ Given this template
+
+ ```txt
+ {% component 'my_comp' %}
+ {{ my_var }}
+ {% endcomponent %}
+ ```
+
+ and this context passed to the render function (AKA root context)
+ ```py
+ { "my_var": 123 }
+ ```
+
+ Then if component "my_comp" defines context
+ ```py
+ { "my_var": 456 }
+ ```
+
+ Then since "my_comp" overrides the varialbe "my_var", so `{{ my_var }}` will equal `456`.
+ """
+
+ PREFER_ROOT = "prefer_root"
+ """
+ This is the same as "allow_override", except any variables defined in the root context
+ take precedence over anything else.
+
+ So if a variable is found in the root context, then root context is used.
+ Otherwise, the context of the component where the slot fill is located is used.
+
+ Example:
+
+ Given this template
+
+ ```txt
+ {% component 'my_comp' %}
+ {{ my_var_one }}
+ {{ my_var_two }}
+ {% endcomponent %}
+ ```
+
+ and this context passed to the render function (AKA root context)
+ ```py
+ { "my_var_one": 123 }
+ ```
+
+ Then if component "my_comp" defines context
+ ```py
+ { "my_var": 456, "my_var_two": "abc" }
+ ```
+
+ Then the rendered `{{ my_var_one }}` will equal to `123`, and `{{ my_var_two }}`
+ will equal to "abc".
+ """
+
+ ISOLATED = "isolated"
+ """
+ This setting makes the slots behave similar to Vue or React, where
+ the slot uses EXCLUSIVELY the root context, and nested components CANNOT
+ override context variables inside the slots.
+
+ Example:
+
+ Given this template
+
+ ```txt
+ {% component 'my_comp' %}
+ {{ my_var }}
+ {% endcomponent %}
+ ```
+
+ and this context passed to the render function (AKA root context)
+ ```py
+ { "my_var": 123 }
+ ```
+
+ Then if component "my_comp" defines context
+ ```py
+ { "my_var": 456 }
+ ```
+
+ Then the rendered `{{ my_var }}` will equal `123`.
+ """
+
+
class AppSettings:
- def __init__(self) -> None:
- self.settings = getattr(settings, "COMPONENTS", {})
+ @property
+ def settings(self) -> Dict:
+ return getattr(settings, "COMPONENTS", {})
@property
def AUTODISCOVER(self) -> bool:
- return self.settings.setdefault("autodiscover", True)
+ return self.settings.get("autodiscover", True)
@property
def LIBRARIES(self) -> List:
- return self.settings.setdefault("libraries", [])
+ return self.settings.get("libraries", [])
@property
def TEMPLATE_CACHE_SIZE(self) -> int:
- return self.settings.setdefault("template_cache_size", 128)
+ return self.settings.get("template_cache_size", 128)
@property
def CONTEXT_BEHAVIOR(self) -> ContextBehavior:
- raw_value = self.settings.setdefault("context_behavior", ContextBehavior.GLOBAL.value)
+ raw_value = self.settings.get("context_behavior", ContextBehavior.GLOBAL.value)
return self._validate_context_behavior(raw_value)
def _validate_context_behavior(self, raw_value: ContextBehavior) -> ContextBehavior:
@@ -37,5 +130,17 @@ def _validate_context_behavior(self, raw_value: ContextBehavior) -> ContextBehav
valid_values = [behavior.value for behavior in ContextBehavior]
raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}")
+ @property
+ def SLOT_CONTEXT_BEHAVIOR(self) -> SlotContextBehavior:
+ raw_value = self.settings.get("slot_context_behavior", SlotContextBehavior.PREFER_ROOT.value)
+ return self._validate_slot_context_behavior(raw_value)
+
+ def _validate_slot_context_behavior(self, raw_value: SlotContextBehavior) -> SlotContextBehavior:
+ try:
+ return SlotContextBehavior(raw_value)
+ except ValueError:
+ valid_values = [behavior.value for behavior in SlotContextBehavior]
+ raise ValueError(f"Invalid slot context behavior: {raw_value}. Valid options are {valid_values}")
+
app_settings = AppSettings()
diff --git a/src/django_components/component.py b/src/django_components/component.py
index fdcc80df..46194f75 100644
--- a/src/django_components/component.py
+++ b/src/django_components/component.py
@@ -2,7 +2,7 @@
import os
import sys
from pathlib import Path
-from typing import Any, ClassVar, Dict, Iterable, List, Mapping, MutableMapping, Optional, Tuple, Type, Union
+from typing import Any, ClassVar, Dict, List, Mapping, MutableMapping, Optional, Tuple, Type, Union
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media, MediaDefiningClass
@@ -20,17 +20,24 @@
# way the two modules depend on one another.
from django_components.component_registry import registry # NOQA
from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA
-from django_components.logger import logger
+from django_components.context import (
+ capture_root_context,
+ get_root_context,
+ set_root_context,
+ set_slot_component_association,
+)
+from django_components.logger import logger, trace_msg
from django_components.middleware import is_dependency_middleware_active
+from django_components.node import walk_nodelist
from django_components.slots import (
- DefaultFillContent,
- ImplicitFillNode,
- NamedFillContent,
- NamedFillNode,
+ DEFAULT_SLOT_KEY,
+ FillContent,
+ FillNode,
SlotName,
+ SlotNode,
render_component_template_with_slots,
)
-from django_components.utils import search
+from django_components.utils import gen_id, search
RENDERED_COMMENT_TEMPLATE = ""
@@ -185,12 +192,14 @@ class Media:
def __init__(
self,
registered_name: Optional[str] = None,
+ component_id: Optional[str] = None,
outer_context: Optional[Context] = None,
- fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]] = (), # type: ignore
+ fill_content: Dict[str, FillContent] = {},
):
self.registered_name: Optional[str] = registered_name
self.outer_context: Context = outer_context or Context()
self.fill_content = fill_content
+ self.component_id = component_id or gen_id()
def __init_subclass__(cls, **kwargs: Any) -> None:
cls.class_hash = hash(inspect.getfile(cls) + cls.__name__)
@@ -230,6 +239,12 @@ def render_js_dependencies(self) -> SafeString:
return mark_safe(f"")
return mark_safe("\n".join(self.media.render_js()))
+ # NOTE: When the template is taken from a file (AKA
+ # specified via `template_name`), then we leverage
+ # Django's template caching. This means that the same
+ # instance of Template is reused. This is important to keep
+ # in mind, because the implication is that we should
+ # treat Templates AND their nodelists as IMMUTABLE.
def get_template(self, context: Mapping) -> Template:
template_string = self.get_template_string(context)
if template_string is not None:
@@ -246,7 +261,7 @@ def get_template(self, context: Mapping) -> Template:
def render(
self,
- context_data: Dict[str, Any],
+ context_data: Union[Dict[str, Any], Context],
slots_data: Optional[Dict[SlotName, str]] = None,
escape_slots_content: bool = True,
) -> str:
@@ -255,14 +270,25 @@ def render(
context = context_data if isinstance(context_data, Context) else Context(context_data)
template = self.get_template(context)
+ # Associate the slots with this component for this context
+ # This allows us to look up component-specific slot fills.
+ def on_node(node: Node) -> None:
+ if isinstance(node, SlotNode):
+ trace_msg("ASSOC", "SLOT", node.name, node.node_id, component_id=self.component_id)
+ set_slot_component_association(context, node.node_id, self.component_id)
+
+ walk_nodelist(template.nodelist, on_node)
+
if slots_data:
self._fill_slots(slots_data, escape_slots_content)
- return render_component_template_with_slots(template, context, self.fill_content, self.registered_name)
+ return render_component_template_with_slots(
+ self.component_id, template, context, self.fill_content, self.registered_name
+ )
def render_to_response(
self,
- context_data: Dict[str, Any],
+ context_data: Union[Dict[str, Any], Context],
slots_data: Optional[Dict[SlotName, str]] = None,
escape_slots_content: bool = True,
*args: Any,
@@ -280,14 +306,13 @@ def _fill_slots(
escape_content: bool = True,
) -> None:
"""Fill component slots outside of template rendering."""
- self.fill_content = [
- (
- slot_name,
+ self.fill_content = {
+ slot_name: FillContent(
TextNode(escape(content) if escape_content else content),
None,
)
for (slot_name, content) in slots_data.items()
- ]
+ }
class ComponentNode(Node):
@@ -299,20 +324,16 @@ def __init__(
context_args: List[FilterExpression],
context_kwargs: Mapping[str, FilterExpression],
isolated_context: bool = False,
- fill_nodes: Union[ImplicitFillNode, Iterable[NamedFillNode]] = (),
+ fill_nodes: Optional[List[FillNode]] = None,
+ component_id: Optional[str] = None,
) -> None:
+ self.component_id = component_id or gen_id()
self.name_fexp = name_fexp
self.context_args = context_args or []
self.context_kwargs = context_kwargs or {}
self.isolated_context = isolated_context
- self.fill_nodes = fill_nodes
- self.nodelist = self._create_nodelist(fill_nodes)
-
- def _create_nodelist(self, fill_nodes: Union[Iterable[Node], ImplicitFillNode]) -> NodeList:
- if isinstance(fill_nodes, ImplicitFillNode):
- return NodeList([fill_nodes])
- else:
- return NodeList(fill_nodes)
+ self.fill_nodes = fill_nodes or []
+ self.nodelist = NodeList(fill_nodes)
def __repr__(self) -> str:
return "".format(
@@ -321,46 +342,64 @@ def __repr__(self) -> str:
)
def render(self, context: Context) -> str:
+ trace_msg("RENDR", "COMP", self.name_fexp, self.component_id)
+
resolved_component_name = self.name_fexp.resolve(context)
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
# component, then call component's context method
# to get values to insert into the context
resolved_context_args = [safe_resolve(arg, context) for arg in self.context_args]
resolved_context_kwargs = {key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()}
- if isinstance(self.fill_nodes, ImplicitFillNode):
- fill_content = self.fill_nodes.nodelist
+ is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
+ if is_default_slot:
+ fill_content: Dict[str, FillContent] = {DEFAULT_SLOT_KEY: FillContent(self.fill_nodes[0].nodelist, None)}
else:
- fill_content = []
+ fill_content = {}
for fill_node in self.fill_nodes:
# Note that outer component context is used to resolve variables in
# fill tag.
resolved_name = fill_node.name_fexp.resolve(context)
resolved_fill_alias = fill_node.resolve_alias(context, resolved_component_name)
- fill_content.append((resolved_name, fill_node.nodelist, resolved_fill_alias))
+ fill_content[resolved_name] = FillContent(fill_node.nodelist, resolved_fill_alias)
component: Component = component_cls(
registered_name=resolved_component_name,
outer_context=context,
fill_content=fill_content,
+ component_id=self.component_id,
)
component_context: dict = component.get_context_data(*resolved_context_args, **resolved_context_kwargs)
+ # Prevent outer context from leaking into the template of the component
if self.isolated_context:
+ # Even if contexts are isolated, we still need to pass down the
+ # 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):
rendered_component = component.render(context)
if is_dependency_middleware_active():
- return RENDERED_COMMENT_TEMPLATE.format(name=resolved_component_name) + rendered_component
+ output = RENDERED_COMMENT_TEMPLATE.format(name=resolved_component_name) + rendered_component
else:
- return rendered_component
+ output = rendered_component
+
+ trace_msg("RENDR", "COMP", self.name_fexp, self.component_id, "...Done!")
+ return output
def safe_resolve(context_item: FilterExpression, context: Context) -> Any:
- """Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
+ """Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item
diff --git a/src/django_components/context.py b/src/django_components/context.py
new file mode 100644
index 00000000..f3d92662
--- /dev/null
+++ b/src/django_components/context.py
@@ -0,0 +1,122 @@
+"""
+This file centralizes various ways we use Django's Context class
+pass data across components, nodes, slots, and contexts.
+
+You can think of the Context as our storage system.
+"""
+
+from copy import copy
+from typing import TYPE_CHECKING, Optional
+
+from django.template import Context
+
+from django_components.logger import trace_msg
+
+if TYPE_CHECKING:
+ from django_components.slots import FillContent
+
+
+_FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
+_OUTER_CONTEXT_CONTEXT_KEY = "_DJANGO_COMPONENTS_OUTER_CONTEXT"
+_SLOT_COMPONENT_ASSOC_KEY = "_DJANGO_COMPONENTS_SLOT_COMP_ASSOC"
+
+
+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.
+
+ See `set_slot_fill` for more details.
+ """
+ trace_msg("GET", "FILL", slot_name, component_id)
+ slot_key = (_FILLED_SLOTS_CONTENT_CONTEXT_KEY, component_id, slot_name)
+ return context.get(slot_key, None)
+
+
+def set_slot_fill(context: Context, component_id: str, slot_name: str, value: "FillContent") -> None:
+ """
+ Use this function to set a slot fill for the current context.
+
+ 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.
+
+ 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)
+ slot_key = (_FILLED_SLOTS_CONTENT_CONTEXT_KEY, component_id, slot_name)
+ context[slot_key] = value
+
+
+def get_root_context(context: Context) -> Optional[Context]:
+ """
+ Use this function to get the root context.
+
+ Root context is the top-most context, AKA the context that was passed to
+ 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)
+
+
+def set_root_context(context: Context, root_ctx: Context) -> None:
+ """
+ Use this function to set the root context.
+
+ Root context is the top-most context, AKA the context that was passed to
+ 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.
+ """
+ context.push({_OUTER_CONTEXT_CONTEXT_KEY: root_ctx})
+
+
+def capture_root_context(context: Context) -> None:
+ """
+ Set the root context if it was not set before.
+
+ Root context is the top-most context, AKA the context that was passed to
+ 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.
+
+ We use SlotNodes to render slot fills. SlotNodes are created only at Template parse time.
+ However, when we are using components with slots in (another) template, 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
+ and FillNodes with a unique component_id, which ties them 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
+ immutable due to caching of Templates by Django.
+
+ Hence, we use the Context to store the associations of SlotNode <-> Component for
+ the current context stack.
+ """
+ key = (_SLOT_COMPONENT_ASSOC_KEY, slot_id)
+ context[key] = component_id
+
+
+def get_slot_component_association(context: Context, slot_id: str) -> str:
+ """
+ Given a slot ID, get the component ID that this slot is associated with
+ in this context.
+
+ See `set_slot_component_association` for more details.
+ """
+ key = (_SLOT_COMPONENT_ASSOC_KEY, slot_id)
+ return context[key]
diff --git a/src/django_components/logger.py b/src/django_components/logger.py
index dbb5f0e7..4dfdb56d 100644
--- a/src/django_components/logger.py
+++ b/src/django_components/logger.py
@@ -1,3 +1,93 @@
import logging
+import sys
+from typing import Any, Dict, Literal, Optional
+
+DEFAULT_TRACE_LEVEL_NUM = 5 # NOTE: MUST be lower than DEBUG which is 10
logger = logging.getLogger("django_components")
+actual_trace_level_num = -1
+
+
+def setup_logging() -> None:
+ # Check if "TRACE" level was already defined. And if so, use its log level.
+ # See https://docs.python.org/3/howto/logging.html#custom-levels
+ global actual_trace_level_num
+ log_levels = _get_log_levels()
+
+ if "TRACE" in log_levels:
+ actual_trace_level_num = log_levels["TRACE"]
+ else:
+ actual_trace_level_num = DEFAULT_TRACE_LEVEL_NUM
+ logging.addLevelName(actual_trace_level_num, "TRACE")
+
+
+def _get_log_levels() -> Dict[str, int]:
+ # Use official API if possible
+ if sys.version_info >= (3, 11):
+ return logging.getLevelNamesMapping()
+ else:
+ return logging._nameToLevel.copy()
+
+
+def trace(logger: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
+ """
+ TRACE level logger.
+
+ To display TRACE logs, set the logging level to 5.
+
+ Example:
+ ```py
+ LOGGING = {
+ "version": 1,
+ "disable_existing_loggers": False,
+ "handlers": {
+ "console": {
+ "class": "logging.StreamHandler",
+ "stream": sys.stdout,
+ },
+ },
+ "loggers": {
+ "django_components": {
+ "level": 5,
+ "handlers": ["console"],
+ },
+ },
+ }
+ ```
+ """
+ if actual_trace_level_num == -1:
+ setup_logging()
+ if logger.isEnabledFor(actual_trace_level_num):
+ logger.log(actual_trace_level_num, message, *args, **kwargs)
+
+
+def trace_msg(
+ action: Literal["PARSE", "ASSOC", "RENDR", "GET", "SET"],
+ node_type: Literal["COMP", "FILL", "SLOT", "IFSB", "N/A"],
+ node_name: str,
+ node_id: str,
+ msg: str = "",
+ component_id: Optional[str] = None,
+) -> None:
+ """
+ TRACE level logger with opinionated format for tracing interaction of components,
+ nodes, and slots. Formats messages like so:
+
+ `"ASSOC SLOT test_slot ID 0088 TO COMP 0087"`
+ """
+ msg_prefix = ""
+ if action == "ASSOC":
+ if not component_id:
+ raise ValueError("component_id must be set for the ASSOC action")
+ msg_prefix = f"TO COMP {component_id}"
+ elif action == "RENDR" and node_type != "COMP":
+ if not component_id:
+ raise ValueError("component_id must be set for the RENDER action")
+ msg_prefix = f"FOR COMP {component_id}"
+
+ msg_parts = [f"{action} {node_type} {node_name} ID {node_id}", *([msg_prefix] if msg_prefix else []), msg]
+ full_msg = " ".join(msg_parts)
+
+ # NOTE: When debugging tests during development, it may be easier to change
+ # this to `print()`
+ trace(logger, full_msg)
diff --git a/src/django_components/node.py b/src/django_components/node.py
new file mode 100644
index 00000000..43b200c9
--- /dev/null
+++ b/src/django_components/node.py
@@ -0,0 +1,38 @@
+from typing import Callable
+
+from django.template.base import Node, NodeList, TextNode
+from django.template.defaulttags import CommentNode
+
+
+def nodelist_has_content(nodelist: NodeList) -> bool:
+ for node in nodelist:
+ if isinstance(node, TextNode) and node.s.isspace():
+ pass
+ elif isinstance(node, CommentNode):
+ pass
+ else:
+ return True
+ return False
+
+
+def walk_nodelist(nodes: NodeList, callback: Callable[[Node], None]) -> None:
+ """Recursively walk a NodeList, calling `callback` for each Node."""
+ node_queue = [*nodes]
+ while len(node_queue):
+ node: Node = node_queue.pop()
+ callback(node)
+ node_queue.extend(get_node_children(node))
+
+
+def get_node_children(node: Node) -> NodeList:
+ """
+ Get child Nodes from Node's nodelist atribute.
+
+ This function is taken from `get_nodes_by_type` method of `django.template.base.Node`.
+ """
+ nodes = NodeList()
+ for attr in node.child_nodelists:
+ nodelist = getattr(node, attr, [])
+ if nodelist:
+ nodes.extend(nodelist)
+ return nodes
diff --git a/src/django_components/slots.py b/src/django_components/slots.py
index 72551803..cdaa21d0 100644
--- a/src/django_components/slots.py
+++ b/src/django_components/slots.py
@@ -1,35 +1,33 @@
import difflib
-import sys
-from typing import Dict, Iterable, List, Optional, Set, Tuple, Type, Union
-
-if sys.version_info[:2] < (3, 9):
- from typing import ChainMap
-else:
- from collections import ChainMap
-
-if sys.version_info[:2] < (3, 10):
- from typing_extensions import TypeAlias
-else:
- from typing import TypeAlias
+import json
+from copy import copy
+from typing import Dict, List, NamedTuple, Optional, Set, Type, Union
from django.template import Context, Template
-from django.template.base import FilterExpression, Node, NodeList, TextNode
+from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode
from django.template.defaulttags import CommentNode
from django.template.exceptions import TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe
-FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
+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.logger import trace_msg
+from django_components.node import nodelist_has_content
+from django_components.utils import gen_id
+
+DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
# Type aliases
SlotName = str
AliasName = str
-DefaultFillContent: TypeAlias = NodeList
-NamedFillContent = Tuple[SlotName, NodeList, Optional[AliasName]]
-FillContent = Tuple[NodeList, Optional[AliasName]]
-FilledSlotsContext = ChainMap[Tuple[SlotName, Template], FillContent]
+class FillContent(NamedTuple):
+ """Data passed from component to slot to render that slot"""
+
+ nodes: NodeList
+ alias: Optional[AliasName]
class UserSlotVar:
@@ -52,36 +50,46 @@ def default(self) -> str:
return mark_safe(self._slot.nodelist.render(self._context))
-class TemplateAwareNodeMixin:
- _template: Template
+class ComponentIdMixin:
+ """
+ Mixin for classes use or pass through component ID.
+
+ We use component IDs to identify which slots should be
+ rendered with which fills for which components.
+ """
+
+ _component_id: str
@property
- def template(self) -> Template:
+ def component_id(self) -> str:
try:
- return self._template
+ return self._component_id
except AttributeError:
raise RuntimeError(
f"Internal error: Instance of {type(self).__name__} was not "
- "linked to Template before use in render() context."
+ "linked to Component before use in render() context. "
+ "Make sure that the 'component_id' field is set."
)
- @template.setter
- def template(self, value: Template) -> None:
- self._template = value
+ @component_id.setter
+ def component_id(self, value: Template) -> None:
+ self._component_id = value
-class SlotNode(Node, TemplateAwareNodeMixin):
+class SlotNode(Node):
def __init__(
self,
name: str,
nodelist: NodeList,
is_required: bool = False,
is_default: bool = False,
+ node_id: Optional[str] = None,
):
self.name = name
self.nodelist = nodelist
self.is_required = is_required
self.is_default = is_default
+ self.node_id = node_id or gen_id()
@property
def active_flags(self) -> List[str]:
@@ -96,20 +104,21 @@ def __repr__(self) -> str:
return f""
def render(self, context: Context) -> SafeString:
- try:
- filled_slots_map: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY]
- except KeyError:
- raise TemplateSyntaxError(f"Attempted to render SlotNode '{self.name}' outside a parent component.")
+ component_id = get_slot_component_association(context, self.node_id)
+ trace_msg("RENDR", "SLOT", self.name, self.node_id, component_id=component_id)
+ slot_fill_content = get_slot_fill(context, component_id, self.name)
extra_context = {}
- try:
- slot_fill_content: FillContent = filled_slots_map[(self.name, self.template)]
- except KeyError:
+
+ # Slot fill was NOT found. Will render the default fill
+ if slot_fill_content is None:
if self.is_required:
raise TemplateSyntaxError(
f"Slot '{self.name}' is marked as 'required' (i.e. non-optional), " f"yet no fill is provided. "
)
nodelist = self.nodelist
+
+ # Slot fill WAS found
else:
nodelist, alias = slot_fill_content
if alias:
@@ -117,35 +126,61 @@ def render(self, context: Context) -> SafeString:
raise TemplateSyntaxError()
extra_context[alias] = UserSlotVar(self, context)
- with context.update(extra_context):
- return nodelist.render(context)
-
-
-class BaseFillNode(Node):
- def __init__(self, nodelist: NodeList):
- self.nodelist: NodeList = nodelist
-
- def __repr__(self) -> str:
- raise NotImplementedError
+ used_ctx = self.resolve_slot_context(context)
+ with used_ctx.update(extra_context):
+ output = nodelist.render(used_ctx)
+
+ trace_msg("RENDR", "SLOT", self.name, self.node_id, component_id=component_id, msg="...Done!")
+ return output
+
+ def resolve_slot_context(self, context: Context) -> Context:
+ """
+ Prepare the context used in a slot fill based on the settings.
+
+ See SlotContextBehavior for the description of each option.
+ """
+ root_ctx = get_root_context(context) or Context()
+
+ if app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ALLOW_OVERRIDE:
+ return context
+ elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.ISOLATED:
+ return root_ctx
+ elif app_settings.SLOT_CONTEXT_BEHAVIOR == SlotContextBehavior.PREFER_ROOT:
+ new_context: Context = copy(context)
+ new_context.update(root_ctx.flatten())
+ return new_context
+ else:
+ raise ValueError(f"Unknown value for SLOT_CONTEXT_BEHAVIOR: '{app_settings.SLOT_CONTEXT_BEHAVIOR}'")
- def render(self, context: Context) -> str:
- raise TemplateSyntaxError(
- "{% fill ... %} block cannot be rendered directly. "
- "You are probably seeing this because you have used one outside "
- "a {% component %} context."
- )
+class FillNode(Node, ComponentIdMixin):
+ is_implicit: bool
+ """
+ Set when a `component` tag pair is passed template content that
+ excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked
+ as 'default'.
+ """
-class NamedFillNode(BaseFillNode):
def __init__(
self,
nodelist: NodeList,
name_fexp: FilterExpression,
alias_fexp: Optional[FilterExpression] = None,
+ is_implicit: bool = False,
+ node_id: Optional[str] = None,
):
- super().__init__(nodelist)
+ self.node_id = node_id or gen_id()
+ self.nodelist = nodelist
self.name_fexp = name_fexp
self.alias_fexp = alias_fexp
+ self.is_implicit = is_implicit
+
+ def render(self, context: Context) -> str:
+ raise TemplateSyntaxError(
+ "{% fill ... %} block cannot be rendered directly. "
+ "You are probably seeing this because you have used one outside "
+ "a {% component %} context."
+ )
def __repr__(self) -> str:
return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>"
@@ -164,17 +199,6 @@ def resolve_alias(self, context: Context, component_name: Optional[str] = None)
return resolved_alias
-class ImplicitFillNode(BaseFillNode):
- """
- Instantiated when a `component` tag pair is passed template content that
- excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked
- as 'default'.
- """
-
- def __repr__(self) -> str:
- return f"<{type(self)} Contents: {repr(self.nodelist)}.>"
-
-
class _IfSlotFilledBranchNode(Node):
def __init__(self, nodelist: NodeList) -> None:
self.nodelist = nodelist
@@ -186,26 +210,22 @@ def evaluate(self, context: Context) -> bool:
raise NotImplementedError
-class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, TemplateAwareNodeMixin):
+class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, ComponentIdMixin):
def __init__(
self,
slot_name: str,
nodelist: NodeList,
is_positive: Union[bool, None] = True,
+ node_id: Optional[str] = None,
) -> None:
self.slot_name = slot_name
self.is_positive: Optional[bool] = is_positive
+ self.node_id = node_id or gen_id()
super().__init__(nodelist)
def evaluate(self, context: Context) -> bool:
- try:
- filled_slots: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY]
- except KeyError:
- raise TemplateSyntaxError(
- f"Attempted to render {type(self).__name__} outside a Component rendering context."
- )
- slot_key = (self.slot_name, self.template)
- is_filled = filled_slots.get(slot_key, None) is not None
+ slot_fill = get_slot_fill(context, self.component_id, self.slot_name)
+ is_filled = slot_fill is not None
# Make polarity switchable.
# i.e. if slot name is NOT filled and is_positive=False,
# then False == False -> True
@@ -244,7 +264,7 @@ def render(self, context: Context) -> str:
def parse_slot_fill_nodes_from_component_nodelist(
component_nodelist: NodeList,
ComponentNodeCls: Type[Node],
-) -> Union[Iterable[NamedFillNode], ImplicitFillNode]:
+) -> List[FillNode]:
"""
Given a component body (`django.template.NodeList`), find all slot fills,
whether defined explicitly with `{% fill %}` or implicitly.
@@ -263,8 +283,8 @@ def parse_slot_fill_nodes_from_component_nodelist(
Then this function returns the nodes (`django.template.Node`) for `fill "first_fill"`
and `fill "second_fill"`.
"""
- fill_nodes: Union[Iterable[NamedFillNode], ImplicitFillNode] = []
- if _block_has_content(component_nodelist):
+ fill_nodes: List[FillNode] = []
+ if nodelist_has_content(component_nodelist):
for parse_fn in (
_try_parse_as_default_fill,
_try_parse_as_named_fill_tag_set,
@@ -286,11 +306,11 @@ def parse_slot_fill_nodes_from_component_nodelist(
def _try_parse_as_named_fill_tag_set(
nodelist: NodeList,
ComponentNodeCls: Type[Node],
-) -> Optional[Iterable[NamedFillNode]]:
+) -> List[FillNode]:
result = []
seen_name_fexps: Set[FilterExpression] = set()
for node in nodelist:
- if isinstance(node, NamedFillNode):
+ if isinstance(node, FillNode):
if node.name_fexp in seen_name_fexps:
raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: "
@@ -303,19 +323,19 @@ def _try_parse_as_named_fill_tag_set(
elif isinstance(node, TextNode) and node.s.isspace():
pass
else:
- return None
+ return []
return result
def _try_parse_as_default_fill(
nodelist: NodeList,
ComponentNodeCls: Type[Node],
-) -> Optional[ImplicitFillNode]:
+) -> List[FillNode]:
nodes_stack: List[Node] = list(nodelist)
while nodes_stack:
node = nodes_stack.pop()
- if isinstance(node, NamedFillNode):
- return None
+ if isinstance(node, FillNode):
+ return []
elif isinstance(node, ComponentNodeCls):
# Stop searching here, as fill tags are permitted inside component blocks
# embedded within a default fill node.
@@ -323,56 +343,59 @@ def _try_parse_as_default_fill(
for nodelist_attr_name in node.child_nodelists:
nodes_stack.extend(getattr(node, nodelist_attr_name, []))
else:
- return ImplicitFillNode(nodelist=nodelist)
-
-
-def _block_has_content(nodelist: NodeList) -> bool:
- for node in nodelist:
- if isinstance(node, TextNode) and node.s.isspace():
- pass
- elif isinstance(node, CommentNode):
- pass
- else:
- return True
- return False
+ return [
+ FillNode(
+ nodelist=nodelist,
+ name_fexp=FilterExpression(json.dumps(DEFAULT_SLOT_KEY), Parser("")),
+ is_implicit=True,
+ )
+ ]
def render_component_template_with_slots(
+ component_id: str,
template: Template,
context: Context,
- fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]],
+ fill_content: Dict[str, FillContent],
registered_name: Optional[str],
) -> str:
"""
- Given a template, context, and slot fills, this function first prepares
- the template to be able to render the fills in the place of slots, and then
- renders the template with given context.
+ This function first prepares the template to be able to render the fills
+ in the place of slots, and then renders the template with given context.
- NOTE: The template is mutated in the process!
+ NOTE: The nodes in the template are mutated in the process!
"""
- prev_filled_slots_context: Optional[FilledSlotsContext] = context.get(FILLED_SLOTS_CONTENT_CONTEXT_KEY)
- updated_filled_slots_context = _prepare_component_template_filled_slot_context(
- template,
- fill_content,
- prev_filled_slots_context,
- registered_name,
- )
- with context.update({FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_filled_slots_context}):
+ # ---- Prepare slot fills ----
+ slot_name2fill_content = _collect_slot_fills_from_component_template(template, fill_content, registered_name)
+
+ # Give slot nodes knowledge of their parent component.
+ for node in template.nodelist.get_nodes_by_type(IfSlotFilledConditionBranchNode):
+ if isinstance(node, IfSlotFilledConditionBranchNode):
+ trace_msg("ASSOC", "IFSB", node.slot_name, node.node_id, component_id=component_id)
+ node.component_id = component_id
+
+ with context.update({}):
+ for slot_name, content_data in slot_name2fill_content.items():
+ # Slots whose content is None (i.e. unfilled) are dropped.
+ if not content_data:
+ continue
+ set_slot_fill(context, component_id, slot_name, content_data)
+
+ # ---- Render ----
return template.render(context)
-def _prepare_component_template_filled_slot_context(
+def _collect_slot_fills_from_component_template(
template: Template,
- fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]],
- slots_context: Optional[FilledSlotsContext],
+ fill_content: Dict[str, FillContent],
registered_name: Optional[str],
-) -> FilledSlotsContext:
- if isinstance(fill_content, NodeList):
- default_fill_content = (fill_content, None)
- named_fills_content = {}
+) -> Dict[SlotName, Optional[FillContent]]:
+ if DEFAULT_SLOT_KEY in fill_content:
+ named_fills_content = fill_content.copy()
+ default_fill_content = named_fills_content.pop(DEFAULT_SLOT_KEY)
else:
+ named_fills_content = fill_content
default_fill_content = None
- named_fills_content = {name: (nodelist, alias) for name, nodelist, alias in list(fill_content)}
# If value is `None`, then slot is unfilled.
slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {}
@@ -380,37 +403,39 @@ def _prepare_component_template_filled_slot_context(
required_slot_names: Set[str] = set()
# Collect fills and check for errors
- for node in template.nodelist.get_nodes_by_type((SlotNode, IfSlotFilledConditionBranchNode)): # type: ignore
- if isinstance(node, SlotNode):
- # Give slot node knowledge of its parent template.
- node.template = template
- slot_name = node.name
- if slot_name in slot_name2fill_content:
+ for node in template.nodelist.get_nodes_by_type(SlotNode):
+ # Type check so the rest of the logic has type of `node` is inferred
+ if not isinstance(node, SlotNode):
+ continue
+
+ slot_name = node.name
+ if slot_name in slot_name2fill_content:
+ raise TemplateSyntaxError(
+ f"Slot name '{slot_name}' re-used within the same template. "
+ f"Slot names must be unique."
+ f"To fix, check template '{template.name}' "
+ f"of component '{registered_name}'."
+ )
+ if node.is_required:
+ required_slot_names.add(node.name)
+
+ content_data: Optional[FillContent] = None # `None` -> unfilled
+ if node.is_default:
+ if default_slot_encountered:
raise TemplateSyntaxError(
- f"Slot name '{slot_name}' re-used within the same template. "
- f"Slot names must be unique."
+ "Only one component slot may be marked as 'default'. "
f"To fix, check template '{template.name}' "
f"of component '{registered_name}'."
)
- content_data: Optional[FillContent] = None # `None` -> unfilled
- if node.is_required:
- required_slot_names.add(node.name)
- if node.is_default:
- if default_slot_encountered:
- raise TemplateSyntaxError(
- "Only one component slot may be marked as 'default'. "
- f"To fix, check template '{template.name}' "
- f"of component '{registered_name}'."
- )
- content_data = default_fill_content
- default_slot_encountered = True
- if not content_data:
- content_data = named_fills_content.get(node.name)
- slot_name2fill_content[slot_name] = content_data
- elif isinstance(node, IfSlotFilledConditionBranchNode):
- node.template = template
- else:
- raise RuntimeError(f"Node of {type(node).__name__} does not require linking.")
+ content_data = default_fill_content
+ default_slot_encountered = True
+
+ # If default fill was not found, try to fill it with named slot
+ # Effectively, this allows to fill in default slot as named ones.
+ if not content_data:
+ content_data = named_fills_content.get(node.name)
+
+ slot_name2fill_content[slot_name] = content_data
# Check: Only component templates that include a 'default' slot
# can be invoked with implicit filling.
@@ -424,6 +449,17 @@ def _prepare_component_template_filled_slot_context(
unfilled_slots: Set[str] = {k for k, v in slot_name2fill_content.items() if v is None}
unmatched_fills: Set[str] = named_fills_content.keys() - slot_name2fill_content.keys()
+ _report_slot_errors(unfilled_slots, unmatched_fills, registered_name, required_slot_names)
+
+ return slot_name2fill_content
+
+
+def _report_slot_errors(
+ unfilled_slots: Set[str],
+ unmatched_fills: Set[str],
+ registered_name: Optional[str],
+ required_slot_names: Set[str],
+) -> None:
# Check that 'required' slots are filled.
for slot_name in unfilled_slots:
if slot_name in required_slot_names:
@@ -454,14 +490,3 @@ def _prepare_component_template_filled_slot_context(
if fuzzy_slot_name_matches:
msg += f"\nDid you mean '{fuzzy_slot_name_matches[0]}'?"
raise TemplateSyntaxError(msg)
-
- # Return updated FILLED_SLOTS_CONTEXT map
- filled_slots_map: Dict[Tuple[SlotName, Template], FillContent] = {
- (slot_name, template): content_data
- for slot_name, content_data in slot_name2fill_content.items()
- if content_data # Slots whose content is None (i.e. unfilled) are dropped.
- }
- if slots_context is not None:
- return slots_context.new_child(filled_slots_map)
- else:
- return ChainMap(filled_slots_map)
diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py
index 677248b2..aeeb79f9 100644
--- a/src/django_components/templatetags/component_tags.py
+++ b/src/django_components/templatetags/component_tags.py
@@ -10,20 +10,22 @@
from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as component_registry
+from django_components.logger import trace_msg
from django_components.middleware import (
CSS_DEPENDENCY_PLACEHOLDER,
JS_DEPENDENCY_PLACEHOLDER,
is_dependency_middleware_active,
)
from django_components.slots import (
+ FillNode,
IfSlotFilledConditionBranchNode,
IfSlotFilledElseBranchNode,
IfSlotFilledNode,
- NamedFillNode,
SlotNode,
_IfSlotFilledBranchNode,
parse_slot_fill_nodes_from_component_nodelist,
)
+from django_components.utils import gen_id
if TYPE_CHECKING:
from django_components.component import Component
@@ -145,18 +147,27 @@ def do_slot(parser: Parser, token: Token) -> SlotNode:
"Order of options is free."
)
+ # Use a unique ID to be able to tie the fill nodes with components and slots
+ # NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
+ slot_id = gen_id()
+ trace_msg("PARSE", "SLOT", slot_name, slot_id)
+
nodelist = parser.parse(parse_until=["endslot"])
parser.delete_first_token()
- return SlotNode(
+ slot_node = SlotNode(
slot_name,
nodelist,
is_required=is_required,
is_default=is_default,
+ node_id=slot_id,
)
+ trace_msg("PARSE", "SLOT", slot_name, slot_id, "...Done!")
+ return slot_node
+
@register.tag("fill")
-def do_fill(parser: Parser, token: Token) -> NamedFillNode:
+def do_fill(parser: Parser, token: Token) -> FillNode:
"""Block tag whose contents 'fill' (are inserted into) an identically named
'slot'-block in the component template referred to by a parent component.
It exists to make component nesting easier.
@@ -179,15 +190,25 @@ def do_fill(parser: Parser, token: Token) -> NamedFillNode:
alias_fexp = FilterExpression(alias, parser)
else:
raise TemplateSyntaxError(f"'{tag}' tag takes either 1 or 3 arguments: Received {len(args)}.")
+
+ # Use a unique ID to be able to tie the fill nodes with components and slots
+ # NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
+ fill_id = gen_id()
+ trace_msg("PARSE", "FILL", tgt_slot_name, fill_id)
+
nodelist = parser.parse(parse_until=["endfill"])
parser.delete_first_token()
- return NamedFillNode(
+ fill_node = FillNode(
nodelist,
name_fexp=FilterExpression(tgt_slot_name, tag),
alias_fexp=alias_fexp,
+ node_id=fill_id,
)
+ trace_msg("PARSE", "FILL", tgt_slot_name, fill_id, "...Done!")
+ return fill_node
+
@register.tag(name="component")
def do_component(parser: Parser, token: Token) -> ComponentNode:
@@ -207,17 +228,31 @@ def do_component(parser: Parser, token: Token) -> ComponentNode:
bits = token.split_contents()
bits, isolated_context = check_for_isolated_context_keyword(bits)
component_name, context_args, context_kwargs = parse_component_with_args(parser, bits, "component")
+
+ # Use a unique ID to be able to tie the fill nodes with components and slots
+ # NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
+ component_id = gen_id()
+ trace_msg("PARSE", "COMP", component_name, component_id)
+
body: NodeList = parser.parse(parse_until=["endcomponent"])
parser.delete_first_token()
fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode)
+
+ # Tag all fill nodes as children of this particular component instance
+ for node in fill_nodes:
+ trace_msg("ASSOC", "FILL", node.name_fexp, node.node_id, component_id=component_id)
+ node.component_id = component_id
+
component_node = ComponentNode(
FilterExpression(component_name, parser),
context_args,
context_kwargs,
isolated_context=isolated_context,
fill_nodes=fill_nodes,
+ component_id=component_id,
)
+ trace_msg("PARSE", "COMP", component_name, component_id, "...Done!")
return component_node
diff --git a/src/django_components/utils.py b/src/django_components/utils.py
index 04c2e522..774c6892 100644
--- a/src/django_components/utils.py
+++ b/src/django_components/utils.py
@@ -35,3 +35,17 @@ def search(search_glob: Optional[str] = None, engine: Optional[Engine] = None) -
component_filenames.append(Path(path))
return SearchResult(searched_dirs=dirs, matched_files=component_filenames)
+
+
+# Global counter to ensure that all IDs generated by `gen_id` WILL be unique
+_id = 0
+
+
+def gen_id(length: int = 5) -> str:
+ """Generate a unique ID that can be associated with a Node"""
+ # Global counter to avoid conflicts
+ global _id
+ _id += 1
+
+ # Pad the ID with `0`s up to 4 digits, e.g. `0007`
+ return f"{_id:04}"
diff --git a/tests/test_component.py b/tests/test_component.py
index 7cf41a5f..0dbff29b 100644
--- a/tests/test_component.py
+++ b/tests/test_component.py
@@ -1,5 +1,6 @@
from pathlib import Path
from textwrap import dedent
+from typing import Any, Dict, Optional
from django.core.exceptions import ImproperlyConfigured
from django.template import Context, Template
@@ -204,6 +205,60 @@ def test_component_with_relative_paths_as_subcomponent(
self.assertIn('', rendered, rendered)
+ def test_component_inside_slot(self):
+ class SlottedComponent(component.Component):
+ template_name = "slotted_template.html"
+
+ def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]:
+ return {
+ "name": name,
+ }
+
+ component.registry.register("test", SlottedComponent)
+
+ self.template = Template(
+ """
+ {% load component_tags %}
+ {% component "test" name='Igor' %}
+ {% fill "header" %}
+ Name: {{ name }}
+ {% endfill %}
+ {% fill "main" %}
+ Day: {{ day }}
+ {% endfill %}
+ {% fill "footer" %}
+ {% component "test" name='Joe2' %}
+ {% fill "header" %}
+ Name2: {{ name }}
+ {% endfill %}
+ {% fill "main" %}
+ Day2: {{ day }}
+ {% endfill %}
+ {% endcomponent %}
+ {% endfill %}
+ {% endcomponent %}
+ """
+ )
+
+ # {{ name }} should be "Jannete" everywhere
+ rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"}))
+ self.assertHTMLEqual(
+ rendered,
+ """
+
+
+ Day: Monday
+
+
+ """,
+ )
+
class InlineComponentTest(SimpleTestCase):
def test_inline_html_component(self):
@@ -482,3 +537,153 @@ def test_instances_of_component_do_not_share_slots(self):
""",
)
+
+
+class SlotBehaviorTests(SimpleTestCase):
+ def setUp(self):
+ class SlottedComponent(component.Component):
+ template_name = "slotted_template.html"
+
+ def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]:
+ return {
+ "name": name,
+ }
+
+ component.registry.register("test", SlottedComponent)
+
+ self.template = Template(
+ """
+ {% load component_tags %}
+ {% component "test" name='Igor' %}
+ {% fill "header" %}
+ Name: {{ name }}
+ {% endfill %}
+ {% fill "main" %}
+ Day: {{ day }}
+ {% endfill %}
+ {% fill "footer" %}
+ {% component "test" name='Joe2' %}
+ {% fill "header" %}
+ Name2: {{ name }}
+ {% endfill %}
+ {% fill "main" %}
+ Day2: {{ day }}
+ {% endfill %}
+ {% endcomponent %}
+ {% endfill %}
+ {% endcomponent %}
+ """
+ )
+
+ @override_settings(
+ COMPONENTS={"slot_context_behavior": "allow_override"},
+ )
+ def test_slot_context_allow_override(self):
+ # {{ name }} should be neither Jannete not empty, because overriden everywhere
+ rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"}))
+ self.assertHTMLEqual(
+ rendered,
+ """
+
+
+ Day: Monday
+
+
+ """,
+ )
+
+ # {{ name }} should be effectively the same as before, because overriden everywhere
+ rendered2 = self.template.render(Context({"day": "Monday"}))
+ self.assertHTMLEqual(rendered2, rendered)
+
+ @override_settings(
+ COMPONENTS={"slot_context_behavior": "isolated"},
+ )
+ def test_slot_context_isolated(self):
+ # {{ name }} should be "Jannete" everywhere
+ rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"}))
+ self.assertHTMLEqual(
+ rendered,
+ """
+
+
+ Day: Monday
+
+
+ """,
+ )
+
+ # {{ name }} should be empty everywhere
+ rendered2 = self.template.render(Context({"day": "Monday"}))
+ self.assertHTMLEqual(
+ rendered2,
+ """
+
+
+ Day: Monday
+
+
+ """,
+ )
+
+ @override_settings(
+ COMPONENTS={
+ "slot_context_behavior": "prefer_root",
+ },
+ )
+ def test_slot_context_prefer_root(self):
+ # {{ name }} should be "Jannete" everywhere
+ rendered = self.template.render(Context({"day": "Monday", "name": "Jannete"}))
+ self.assertHTMLEqual(
+ rendered,
+ """
+
+
+ Day: Monday
+
+
+ """,
+ )
+
+ # {{ name }} should be neither "Jannete" nor empty anywhere
+ rendered = self.template.render(Context({"day": "Monday"}))
+ self.assertHTMLEqual(
+ rendered,
+ """
+
+
+ Day: Monday
+
+
+ """,
+ )
diff --git a/tests/test_context.py b/tests/test_context.py
index b5c3c2b6..4a58a0cc 100644
--- a/tests/test_context.py
+++ b/tests/test_context.py
@@ -1,6 +1,7 @@
from unittest.mock import PropertyMock, patch
from django.template import Context, Template
+from django.test import override_settings
from django_components import component
@@ -65,7 +66,7 @@ class OuterContextComponent(component.Component):
template_name = "simple_template.html"
def get_context_data(self):
- return self.outer_context
+ return self.outer_context.flatten()
component.registry.register(name="parent_component", component=ParentComponent)
@@ -385,7 +386,21 @@ def test_component_excludes_variable_with_isolated_context_from_settings(
class OuterContextPropertyTests(SimpleTestCase):
- def test_outer_context_property_with_component(self):
+ @override_settings(
+ COMPONENTS={"context_behavior": "global"},
+ )
+ def test_outer_context_property_with_component_global(self):
+ template = Template(
+ "{% load component_tags %}{% component_dependencies %}"
+ "{% component 'outer_context_component' only %}{% endcomponent %}"
+ )
+ rendered = template.render(Context({"variable": "outer_value"})).strip()
+ self.assertIn("outer_value", rendered, rendered)
+
+ @override_settings(
+ COMPONENTS={"context_behavior": "isolated"},
+ )
+ def test_outer_context_property_with_component_isolated(self):
template = Template(
"{% load component_tags %}{% component_dependencies %}"
"{% component 'outer_context_component' only %}{% endcomponent %}"
diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py
index 91bfdbb2..62c71b0c 100644
--- a/tests/test_templatetags.py
+++ b/tests/test_templatetags.py
@@ -1320,8 +1320,8 @@ def test_inner_slot_iteration_nested(self):
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
objects = [
- {"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]},
- {"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]},
+ {"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
+ {"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
]
template = Template(
@@ -1343,10 +1343,10 @@ def test_inner_slot_iteration_nested(self):
self.assertHTMLEqual(
rendered,
"""
- OBJECT1_ITER1
- OBJECT2_ITER1
- OBJECT1_ITER2
- OBJECT2_ITER2
+ ITER1_OBJ1
+ ITER1_OBJ2
+ ITER2_OBJ1
+ ITER2_OBJ2
""",
)
@@ -1354,8 +1354,8 @@ def test_inner_slot_iteration_nested_with_outer_scope_variable(self):
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
objects = [
- {"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]},
- {"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]},
+ {"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
+ {"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
]
template = Template(
@@ -1389,14 +1389,14 @@ def test_inner_slot_iteration_nested_with_outer_scope_variable(self):
"""
OUTER_SCOPE_VARIABLE1
OUTER_SCOPE_VARIABLE2
- OBJECT1_ITER1
+ ITER1_OBJ1
OUTER_SCOPE_VARIABLE2
- OBJECT2_ITER1
+ ITER1_OBJ2
OUTER_SCOPE_VARIABLE1
OUTER_SCOPE_VARIABLE2
- OBJECT1_ITER2
+ ITER2_OBJ1
OUTER_SCOPE_VARIABLE2
- OBJECT2_ITER2
+ ITER2_OBJ2
""",
)
@@ -1404,8 +1404,8 @@ def test_inner_slot_iteration_nested_with_slot_default(self):
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
objects = [
- {"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]},
- {"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]},
+ {"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
+ {"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
]
template = Template(
@@ -1427,10 +1427,10 @@ def test_inner_slot_iteration_nested_with_slot_default(self):
self.assertHTMLEqual(
rendered,
"""
- OBJECT1_ITER1 default
- OBJECT2_ITER1 default
- OBJECT1_ITER2 default
- OBJECT2_ITER2 default
+ ITER1_OBJ1 default
+ ITER1_OBJ2 default
+ ITER2_OBJ1 default
+ ITER2_OBJ2 default
""",
)
@@ -1440,8 +1440,8 @@ def test_inner_slot_iteration_nested_with_slot_default_and_outer_scope_variable(
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
objects = [
- {"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]},
- {"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]},
+ {"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
+ {"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
]
template = Template(
@@ -1475,13 +1475,13 @@ def test_inner_slot_iteration_nested_with_slot_default_and_outer_scope_variable(
"""
OUTER_SCOPE_VARIABLE1
OUTER_SCOPE_VARIABLE2
- OBJECT1_ITER1 default
+ ITER1_OBJ1 default
OUTER_SCOPE_VARIABLE2
- OBJECT2_ITER1 default
+ ITER1_OBJ2 default
OUTER_SCOPE_VARIABLE1
OUTER_SCOPE_VARIABLE2
- OBJECT1_ITER2 default
+ ITER2_OBJ1 default
OUTER_SCOPE_VARIABLE2
- OBJECT2_ITER2 default
+ ITER2_OBJ2 default
""",
)