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, + """ + +
Name: Jannete
+
Day: Monday
+
+ +
Name2: Jannete
+
Day2: Monday
+
Default footer
+
+
+
+ """, + ) + 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, + """ + +
Name: Igor
+
Day: Monday
+
+ +
Name2: Joe2
+
Day2: Monday
+
Default footer
+
+
+
+ """, + ) + + # {{ 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, + """ + +
Name: Jannete
+
Day: Monday
+
+ +
Name2: Jannete
+
Day2: Monday
+
Default footer
+
+
+
+ """, + ) + + # {{ name }} should be empty everywhere + rendered2 = self.template.render(Context({"day": "Monday"})) + self.assertHTMLEqual( + rendered2, + """ + +
Name:
+
Day: Monday
+
+ +
Name2:
+
Day2: Monday
+
Default footer
+
+
+
+ """, + ) + + @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, + """ + +
Name: Jannete
+
Day: Monday
+
+ +
Name2: Jannete
+
Day2: Monday
+
Default footer
+
+
+
+ """, + ) + + # {{ name }} should be neither "Jannete" nor empty anywhere + rendered = self.template.render(Context({"day": "Monday"})) + self.assertHTMLEqual( + rendered, + """ + +
Name: Igor
+
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 """, )