mirror of
https://github.com/django-components/django-components.git
synced 2025-09-26 15:39:08 +00:00
Merge pull request #437 from JuroOravec/350-feat-slot-context-resolution
This commit is contained in:
commit
c422f20ee4
13 changed files with 940 additions and 221 deletions
23
README.md
23
README.md
|
@ -20,6 +20,8 @@ Read on to learn about the details!
|
||||||
|
|
||||||
## Release notes
|
## 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.
|
🚨📢 **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.
|
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
|
## 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.
|
Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). This can help with troubleshooting.
|
||||||
|
|
|
@ -80,6 +80,14 @@ TEMPLATES = [
|
||||||
|
|
||||||
WSGI_APPLICATION = "sampleproject.wsgi.application"
|
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
|
# Database
|
||||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
|
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List
|
from typing import Dict, List
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
@ -9,25 +9,118 @@ class ContextBehavior(str, Enum):
|
||||||
ISOLATED = "isolated"
|
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:
|
class AppSettings:
|
||||||
def __init__(self) -> None:
|
@property
|
||||||
self.settings = getattr(settings, "COMPONENTS", {})
|
def settings(self) -> Dict:
|
||||||
|
return getattr(settings, "COMPONENTS", {})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def AUTODISCOVER(self) -> bool:
|
def AUTODISCOVER(self) -> bool:
|
||||||
return self.settings.setdefault("autodiscover", True)
|
return self.settings.get("autodiscover", True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def LIBRARIES(self) -> List:
|
def LIBRARIES(self) -> List:
|
||||||
return self.settings.setdefault("libraries", [])
|
return self.settings.get("libraries", [])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def TEMPLATE_CACHE_SIZE(self) -> int:
|
def TEMPLATE_CACHE_SIZE(self) -> int:
|
||||||
return self.settings.setdefault("template_cache_size", 128)
|
return self.settings.get("template_cache_size", 128)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def CONTEXT_BEHAVIOR(self) -> ContextBehavior:
|
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)
|
return self._validate_context_behavior(raw_value)
|
||||||
|
|
||||||
def _validate_context_behavior(self, raw_value: ContextBehavior) -> ContextBehavior:
|
def _validate_context_behavior(self, raw_value: ContextBehavior) -> ContextBehavior:
|
||||||
|
@ -37,5 +130,17 @@ class AppSettings:
|
||||||
valid_values = [behavior.value for behavior in ContextBehavior]
|
valid_values = [behavior.value for behavior in ContextBehavior]
|
||||||
raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}")
|
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()
|
app_settings = AppSettings()
|
||||||
|
|
|
@ -2,7 +2,7 @@ import inspect
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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.core.exceptions import ImproperlyConfigured
|
||||||
from django.forms.widgets import Media, MediaDefiningClass
|
from django.forms.widgets import Media, MediaDefiningClass
|
||||||
|
@ -20,17 +20,24 @@ from django.views import View
|
||||||
# way the two modules depend on one another.
|
# way the two modules depend on one another.
|
||||||
from django_components.component_registry import registry # NOQA
|
from django_components.component_registry import registry # NOQA
|
||||||
from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA
|
from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA
|
||||||
from django_components.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.middleware import is_dependency_middleware_active
|
||||||
|
from django_components.node import walk_nodelist
|
||||||
from django_components.slots import (
|
from django_components.slots import (
|
||||||
DefaultFillContent,
|
DEFAULT_SLOT_KEY,
|
||||||
ImplicitFillNode,
|
FillContent,
|
||||||
NamedFillContent,
|
FillNode,
|
||||||
NamedFillNode,
|
|
||||||
SlotName,
|
SlotName,
|
||||||
|
SlotNode,
|
||||||
render_component_template_with_slots,
|
render_component_template_with_slots,
|
||||||
)
|
)
|
||||||
from django_components.utils import search
|
from django_components.utils import gen_id, search
|
||||||
|
|
||||||
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
|
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
|
||||||
|
|
||||||
|
@ -185,12 +192,14 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
registered_name: Optional[str] = None,
|
registered_name: Optional[str] = None,
|
||||||
|
component_id: Optional[str] = None,
|
||||||
outer_context: Optional[Context] = 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.registered_name: Optional[str] = registered_name
|
||||||
self.outer_context: Context = outer_context or Context()
|
self.outer_context: Context = outer_context or Context()
|
||||||
self.fill_content = fill_content
|
self.fill_content = fill_content
|
||||||
|
self.component_id = component_id or gen_id()
|
||||||
|
|
||||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||||
cls.class_hash = hash(inspect.getfile(cls) + cls.__name__)
|
cls.class_hash = hash(inspect.getfile(cls) + cls.__name__)
|
||||||
|
@ -230,6 +239,12 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
||||||
return mark_safe(f"<script>{self.js}</script>")
|
return mark_safe(f"<script>{self.js}</script>")
|
||||||
return mark_safe("\n".join(self.media.render_js()))
|
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:
|
def get_template(self, context: Mapping) -> Template:
|
||||||
template_string = self.get_template_string(context)
|
template_string = self.get_template_string(context)
|
||||||
if template_string is not None:
|
if template_string is not None:
|
||||||
|
@ -246,7 +261,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
||||||
|
|
||||||
def render(
|
def render(
|
||||||
self,
|
self,
|
||||||
context_data: Dict[str, Any],
|
context_data: Union[Dict[str, Any], Context],
|
||||||
slots_data: Optional[Dict[SlotName, str]] = None,
|
slots_data: Optional[Dict[SlotName, str]] = None,
|
||||||
escape_slots_content: bool = True,
|
escape_slots_content: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
@ -255,14 +270,25 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
||||||
context = context_data if isinstance(context_data, Context) else Context(context_data)
|
context = context_data if isinstance(context_data, Context) else Context(context_data)
|
||||||
template = self.get_template(context)
|
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:
|
if slots_data:
|
||||||
self._fill_slots(slots_data, escape_slots_content)
|
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(
|
def render_to_response(
|
||||||
self,
|
self,
|
||||||
context_data: Dict[str, Any],
|
context_data: Union[Dict[str, Any], Context],
|
||||||
slots_data: Optional[Dict[SlotName, str]] = None,
|
slots_data: Optional[Dict[SlotName, str]] = None,
|
||||||
escape_slots_content: bool = True,
|
escape_slots_content: bool = True,
|
||||||
*args: Any,
|
*args: Any,
|
||||||
|
@ -280,14 +306,13 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
||||||
escape_content: bool = True,
|
escape_content: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Fill component slots outside of template rendering."""
|
"""Fill component slots outside of template rendering."""
|
||||||
self.fill_content = [
|
self.fill_content = {
|
||||||
(
|
slot_name: FillContent(
|
||||||
slot_name,
|
|
||||||
TextNode(escape(content) if escape_content else content),
|
TextNode(escape(content) if escape_content else content),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
for (slot_name, content) in slots_data.items()
|
for (slot_name, content) in slots_data.items()
|
||||||
]
|
}
|
||||||
|
|
||||||
|
|
||||||
class ComponentNode(Node):
|
class ComponentNode(Node):
|
||||||
|
@ -299,20 +324,16 @@ class ComponentNode(Node):
|
||||||
context_args: List[FilterExpression],
|
context_args: List[FilterExpression],
|
||||||
context_kwargs: Mapping[str, FilterExpression],
|
context_kwargs: Mapping[str, FilterExpression],
|
||||||
isolated_context: bool = False,
|
isolated_context: bool = False,
|
||||||
fill_nodes: Union[ImplicitFillNode, Iterable[NamedFillNode]] = (),
|
fill_nodes: Optional[List[FillNode]] = None,
|
||||||
|
component_id: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
self.component_id = component_id or gen_id()
|
||||||
self.name_fexp = name_fexp
|
self.name_fexp = name_fexp
|
||||||
self.context_args = context_args or []
|
self.context_args = context_args or []
|
||||||
self.context_kwargs = context_kwargs or {}
|
self.context_kwargs = context_kwargs or {}
|
||||||
self.isolated_context = isolated_context
|
self.isolated_context = isolated_context
|
||||||
self.fill_nodes = fill_nodes
|
self.fill_nodes = fill_nodes or []
|
||||||
self.nodelist = self._create_nodelist(fill_nodes)
|
self.nodelist = 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)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return "<ComponentNode: {}. Contents: {!r}>".format(
|
return "<ComponentNode: {}. Contents: {!r}>".format(
|
||||||
|
@ -321,43 +342,61 @@ class ComponentNode(Node):
|
||||||
)
|
)
|
||||||
|
|
||||||
def render(self, context: Context) -> 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)
|
resolved_component_name = self.name_fexp.resolve(context)
|
||||||
component_cls: Type[Component] = registry.get(resolved_component_name)
|
component_cls: Type[Component] = registry.get(resolved_component_name)
|
||||||
|
|
||||||
|
# If this is the outer-/top-most component node, then save the outer context,
|
||||||
|
# so it can be used by nested Slots.
|
||||||
|
capture_root_context(context)
|
||||||
|
|
||||||
# Resolve FilterExpressions and Variables that were passed as args to the
|
# Resolve FilterExpressions and Variables that were passed as args to the
|
||||||
# component, then call component's context method
|
# component, then call component's context method
|
||||||
# to get values to insert into the context
|
# to get values to insert into the context
|
||||||
resolved_context_args = [safe_resolve(arg, context) for arg in self.context_args]
|
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()}
|
resolved_context_kwargs = {key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()}
|
||||||
|
|
||||||
if isinstance(self.fill_nodes, ImplicitFillNode):
|
is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
|
||||||
fill_content = self.fill_nodes.nodelist
|
if is_default_slot:
|
||||||
|
fill_content: Dict[str, FillContent] = {DEFAULT_SLOT_KEY: FillContent(self.fill_nodes[0].nodelist, None)}
|
||||||
else:
|
else:
|
||||||
fill_content = []
|
fill_content = {}
|
||||||
for fill_node in self.fill_nodes:
|
for fill_node in self.fill_nodes:
|
||||||
# Note that outer component context is used to resolve variables in
|
# Note that outer component context is used to resolve variables in
|
||||||
# fill tag.
|
# fill tag.
|
||||||
resolved_name = fill_node.name_fexp.resolve(context)
|
resolved_name = fill_node.name_fexp.resolve(context)
|
||||||
resolved_fill_alias = fill_node.resolve_alias(context, resolved_component_name)
|
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(
|
component: Component = component_cls(
|
||||||
registered_name=resolved_component_name,
|
registered_name=resolved_component_name,
|
||||||
outer_context=context,
|
outer_context=context,
|
||||||
fill_content=fill_content,
|
fill_content=fill_content,
|
||||||
|
component_id=self.component_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
component_context: dict = component.get_context_data(*resolved_context_args, **resolved_context_kwargs)
|
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:
|
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()
|
context = context.new()
|
||||||
|
set_root_context(context, root_ctx)
|
||||||
|
|
||||||
with context.update(component_context):
|
with context.update(component_context):
|
||||||
rendered_component = component.render(context)
|
rendered_component = component.render(context)
|
||||||
|
|
||||||
if is_dependency_middleware_active():
|
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:
|
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:
|
def safe_resolve(context_item: FilterExpression, context: Context) -> Any:
|
||||||
|
|
122
src/django_components/context.py
Normal file
122
src/django_components/context.py
Normal file
|
@ -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]
|
|
@ -1,3 +1,93 @@
|
||||||
import logging
|
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")
|
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)
|
||||||
|
|
38
src/django_components/node.py
Normal file
38
src/django_components/node.py
Normal file
|
@ -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
|
|
@ -1,35 +1,33 @@
|
||||||
import difflib
|
import difflib
|
||||||
import sys
|
import json
|
||||||
from typing import Dict, Iterable, List, Optional, Set, Tuple, Type, Union
|
from copy import copy
|
||||||
|
from typing import Dict, List, NamedTuple, Optional, Set, 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
|
|
||||||
|
|
||||||
from django.template import Context, Template
|
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.defaulttags import CommentNode
|
||||||
from django.template.exceptions import TemplateSyntaxError
|
from django.template.exceptions import TemplateSyntaxError
|
||||||
from django.utils.safestring import SafeString, mark_safe
|
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
|
# Type aliases
|
||||||
|
|
||||||
SlotName = str
|
SlotName = str
|
||||||
AliasName = str
|
AliasName = str
|
||||||
|
|
||||||
DefaultFillContent: TypeAlias = NodeList
|
|
||||||
NamedFillContent = Tuple[SlotName, NodeList, Optional[AliasName]]
|
|
||||||
|
|
||||||
FillContent = Tuple[NodeList, Optional[AliasName]]
|
class FillContent(NamedTuple):
|
||||||
FilledSlotsContext = ChainMap[Tuple[SlotName, Template], FillContent]
|
"""Data passed from component to slot to render that slot"""
|
||||||
|
|
||||||
|
nodes: NodeList
|
||||||
|
alias: Optional[AliasName]
|
||||||
|
|
||||||
|
|
||||||
class UserSlotVar:
|
class UserSlotVar:
|
||||||
|
@ -52,36 +50,46 @@ class UserSlotVar:
|
||||||
return mark_safe(self._slot.nodelist.render(self._context))
|
return mark_safe(self._slot.nodelist.render(self._context))
|
||||||
|
|
||||||
|
|
||||||
class TemplateAwareNodeMixin:
|
class ComponentIdMixin:
|
||||||
_template: Template
|
"""
|
||||||
|
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
|
@property
|
||||||
def template(self) -> Template:
|
def component_id(self) -> str:
|
||||||
try:
|
try:
|
||||||
return self._template
|
return self._component_id
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Internal error: Instance of {type(self).__name__} was not "
|
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
|
@component_id.setter
|
||||||
def template(self, value: Template) -> None:
|
def component_id(self, value: Template) -> None:
|
||||||
self._template = value
|
self._component_id = value
|
||||||
|
|
||||||
|
|
||||||
class SlotNode(Node, TemplateAwareNodeMixin):
|
class SlotNode(Node):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
nodelist: NodeList,
|
nodelist: NodeList,
|
||||||
is_required: bool = False,
|
is_required: bool = False,
|
||||||
is_default: bool = False,
|
is_default: bool = False,
|
||||||
|
node_id: Optional[str] = None,
|
||||||
):
|
):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.nodelist = nodelist
|
self.nodelist = nodelist
|
||||||
self.is_required = is_required
|
self.is_required = is_required
|
||||||
self.is_default = is_default
|
self.is_default = is_default
|
||||||
|
self.node_id = node_id or gen_id()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active_flags(self) -> List[str]:
|
def active_flags(self) -> List[str]:
|
||||||
|
@ -96,20 +104,21 @@ class SlotNode(Node, TemplateAwareNodeMixin):
|
||||||
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
|
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
|
||||||
|
|
||||||
def render(self, context: Context) -> SafeString:
|
def render(self, context: Context) -> SafeString:
|
||||||
try:
|
component_id = get_slot_component_association(context, self.node_id)
|
||||||
filled_slots_map: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY]
|
trace_msg("RENDR", "SLOT", self.name, self.node_id, component_id=component_id)
|
||||||
except KeyError:
|
|
||||||
raise TemplateSyntaxError(f"Attempted to render SlotNode '{self.name}' outside a parent component.")
|
|
||||||
|
|
||||||
|
slot_fill_content = get_slot_fill(context, component_id, self.name)
|
||||||
extra_context = {}
|
extra_context = {}
|
||||||
try:
|
|
||||||
slot_fill_content: FillContent = filled_slots_map[(self.name, self.template)]
|
# Slot fill was NOT found. Will render the default fill
|
||||||
except KeyError:
|
if slot_fill_content is None:
|
||||||
if self.is_required:
|
if self.is_required:
|
||||||
raise TemplateSyntaxError(
|
raise TemplateSyntaxError(
|
||||||
f"Slot '{self.name}' is marked as 'required' (i.e. non-optional), " f"yet no fill is provided. "
|
f"Slot '{self.name}' is marked as 'required' (i.e. non-optional), " f"yet no fill is provided. "
|
||||||
)
|
)
|
||||||
nodelist = self.nodelist
|
nodelist = self.nodelist
|
||||||
|
|
||||||
|
# Slot fill WAS found
|
||||||
else:
|
else:
|
||||||
nodelist, alias = slot_fill_content
|
nodelist, alias = slot_fill_content
|
||||||
if alias:
|
if alias:
|
||||||
|
@ -117,16 +126,54 @@ class SlotNode(Node, TemplateAwareNodeMixin):
|
||||||
raise TemplateSyntaxError()
|
raise TemplateSyntaxError()
|
||||||
extra_context[alias] = UserSlotVar(self, context)
|
extra_context[alias] = UserSlotVar(self, context)
|
||||||
|
|
||||||
with context.update(extra_context):
|
used_ctx = self.resolve_slot_context(context)
|
||||||
return nodelist.render(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}'")
|
||||||
|
|
||||||
|
|
||||||
class BaseFillNode(Node):
|
class FillNode(Node, ComponentIdMixin):
|
||||||
def __init__(self, nodelist: NodeList):
|
is_implicit: bool
|
||||||
self.nodelist: NodeList = nodelist
|
"""
|
||||||
|
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'.
|
||||||
|
"""
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __init__(
|
||||||
raise NotImplementedError
|
self,
|
||||||
|
nodelist: NodeList,
|
||||||
|
name_fexp: FilterExpression,
|
||||||
|
alias_fexp: Optional[FilterExpression] = None,
|
||||||
|
is_implicit: bool = False,
|
||||||
|
node_id: Optional[str] = None,
|
||||||
|
):
|
||||||
|
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:
|
def render(self, context: Context) -> str:
|
||||||
raise TemplateSyntaxError(
|
raise TemplateSyntaxError(
|
||||||
|
@ -135,18 +182,6 @@ class BaseFillNode(Node):
|
||||||
"a {% component %} context."
|
"a {% component %} context."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class NamedFillNode(BaseFillNode):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
nodelist: NodeList,
|
|
||||||
name_fexp: FilterExpression,
|
|
||||||
alias_fexp: Optional[FilterExpression] = None,
|
|
||||||
):
|
|
||||||
super().__init__(nodelist)
|
|
||||||
self.name_fexp = name_fexp
|
|
||||||
self.alias_fexp = alias_fexp
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>"
|
return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>"
|
||||||
|
|
||||||
|
@ -164,17 +199,6 @@ class NamedFillNode(BaseFillNode):
|
||||||
return resolved_alias
|
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):
|
class _IfSlotFilledBranchNode(Node):
|
||||||
def __init__(self, nodelist: NodeList) -> None:
|
def __init__(self, nodelist: NodeList) -> None:
|
||||||
self.nodelist = nodelist
|
self.nodelist = nodelist
|
||||||
|
@ -186,26 +210,22 @@ class _IfSlotFilledBranchNode(Node):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, TemplateAwareNodeMixin):
|
class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, ComponentIdMixin):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
slot_name: str,
|
slot_name: str,
|
||||||
nodelist: NodeList,
|
nodelist: NodeList,
|
||||||
is_positive: Union[bool, None] = True,
|
is_positive: Union[bool, None] = True,
|
||||||
|
node_id: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.slot_name = slot_name
|
self.slot_name = slot_name
|
||||||
self.is_positive: Optional[bool] = is_positive
|
self.is_positive: Optional[bool] = is_positive
|
||||||
|
self.node_id = node_id or gen_id()
|
||||||
super().__init__(nodelist)
|
super().__init__(nodelist)
|
||||||
|
|
||||||
def evaluate(self, context: Context) -> bool:
|
def evaluate(self, context: Context) -> bool:
|
||||||
try:
|
slot_fill = get_slot_fill(context, self.component_id, self.slot_name)
|
||||||
filled_slots: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY]
|
is_filled = slot_fill is not None
|
||||||
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
|
|
||||||
# Make polarity switchable.
|
# Make polarity switchable.
|
||||||
# i.e. if slot name is NOT filled and is_positive=False,
|
# i.e. if slot name is NOT filled and is_positive=False,
|
||||||
# then False == False -> True
|
# then False == False -> True
|
||||||
|
@ -244,7 +264,7 @@ class IfSlotFilledNode(Node):
|
||||||
def parse_slot_fill_nodes_from_component_nodelist(
|
def parse_slot_fill_nodes_from_component_nodelist(
|
||||||
component_nodelist: NodeList,
|
component_nodelist: NodeList,
|
||||||
ComponentNodeCls: Type[Node],
|
ComponentNodeCls: Type[Node],
|
||||||
) -> Union[Iterable[NamedFillNode], ImplicitFillNode]:
|
) -> List[FillNode]:
|
||||||
"""
|
"""
|
||||||
Given a component body (`django.template.NodeList`), find all slot fills,
|
Given a component body (`django.template.NodeList`), find all slot fills,
|
||||||
whether defined explicitly with `{% fill %}` or implicitly.
|
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"`
|
Then this function returns the nodes (`django.template.Node`) for `fill "first_fill"`
|
||||||
and `fill "second_fill"`.
|
and `fill "second_fill"`.
|
||||||
"""
|
"""
|
||||||
fill_nodes: Union[Iterable[NamedFillNode], ImplicitFillNode] = []
|
fill_nodes: List[FillNode] = []
|
||||||
if _block_has_content(component_nodelist):
|
if nodelist_has_content(component_nodelist):
|
||||||
for parse_fn in (
|
for parse_fn in (
|
||||||
_try_parse_as_default_fill,
|
_try_parse_as_default_fill,
|
||||||
_try_parse_as_named_fill_tag_set,
|
_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(
|
def _try_parse_as_named_fill_tag_set(
|
||||||
nodelist: NodeList,
|
nodelist: NodeList,
|
||||||
ComponentNodeCls: Type[Node],
|
ComponentNodeCls: Type[Node],
|
||||||
) -> Optional[Iterable[NamedFillNode]]:
|
) -> List[FillNode]:
|
||||||
result = []
|
result = []
|
||||||
seen_name_fexps: Set[FilterExpression] = set()
|
seen_name_fexps: Set[FilterExpression] = set()
|
||||||
for node in nodelist:
|
for node in nodelist:
|
||||||
if isinstance(node, NamedFillNode):
|
if isinstance(node, FillNode):
|
||||||
if node.name_fexp in seen_name_fexps:
|
if node.name_fexp in seen_name_fexps:
|
||||||
raise TemplateSyntaxError(
|
raise TemplateSyntaxError(
|
||||||
f"Multiple fill tags cannot target the same slot name: "
|
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():
|
elif isinstance(node, TextNode) and node.s.isspace():
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
return None
|
return []
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _try_parse_as_default_fill(
|
def _try_parse_as_default_fill(
|
||||||
nodelist: NodeList,
|
nodelist: NodeList,
|
||||||
ComponentNodeCls: Type[Node],
|
ComponentNodeCls: Type[Node],
|
||||||
) -> Optional[ImplicitFillNode]:
|
) -> List[FillNode]:
|
||||||
nodes_stack: List[Node] = list(nodelist)
|
nodes_stack: List[Node] = list(nodelist)
|
||||||
while nodes_stack:
|
while nodes_stack:
|
||||||
node = nodes_stack.pop()
|
node = nodes_stack.pop()
|
||||||
if isinstance(node, NamedFillNode):
|
if isinstance(node, FillNode):
|
||||||
return None
|
return []
|
||||||
elif isinstance(node, ComponentNodeCls):
|
elif isinstance(node, ComponentNodeCls):
|
||||||
# Stop searching here, as fill tags are permitted inside component blocks
|
# Stop searching here, as fill tags are permitted inside component blocks
|
||||||
# embedded within a default fill node.
|
# embedded within a default fill node.
|
||||||
|
@ -323,56 +343,59 @@ def _try_parse_as_default_fill(
|
||||||
for nodelist_attr_name in node.child_nodelists:
|
for nodelist_attr_name in node.child_nodelists:
|
||||||
nodes_stack.extend(getattr(node, nodelist_attr_name, []))
|
nodes_stack.extend(getattr(node, nodelist_attr_name, []))
|
||||||
else:
|
else:
|
||||||
return ImplicitFillNode(nodelist=nodelist)
|
return [
|
||||||
|
FillNode(
|
||||||
|
nodelist=nodelist,
|
||||||
def _block_has_content(nodelist: NodeList) -> bool:
|
name_fexp=FilterExpression(json.dumps(DEFAULT_SLOT_KEY), Parser("")),
|
||||||
for node in nodelist:
|
is_implicit=True,
|
||||||
if isinstance(node, TextNode) and node.s.isspace():
|
)
|
||||||
pass
|
]
|
||||||
elif isinstance(node, CommentNode):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def render_component_template_with_slots(
|
def render_component_template_with_slots(
|
||||||
|
component_id: str,
|
||||||
template: Template,
|
template: Template,
|
||||||
context: Context,
|
context: Context,
|
||||||
fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]],
|
fill_content: Dict[str, FillContent],
|
||||||
registered_name: Optional[str],
|
registered_name: Optional[str],
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Given a template, context, and slot fills, this function first prepares
|
This function first prepares the template to be able to render the fills
|
||||||
the template to be able to render the fills in the place of slots, and then
|
in the place of slots, and then renders the template with given context.
|
||||||
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)
|
# ---- Prepare slot fills ----
|
||||||
updated_filled_slots_context = _prepare_component_template_filled_slot_context(
|
slot_name2fill_content = _collect_slot_fills_from_component_template(template, fill_content, registered_name)
|
||||||
template,
|
|
||||||
fill_content,
|
# Give slot nodes knowledge of their parent component.
|
||||||
prev_filled_slots_context,
|
for node in template.nodelist.get_nodes_by_type(IfSlotFilledConditionBranchNode):
|
||||||
registered_name,
|
if isinstance(node, IfSlotFilledConditionBranchNode):
|
||||||
)
|
trace_msg("ASSOC", "IFSB", node.slot_name, node.node_id, component_id=component_id)
|
||||||
with context.update({FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_filled_slots_context}):
|
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)
|
return template.render(context)
|
||||||
|
|
||||||
|
|
||||||
def _prepare_component_template_filled_slot_context(
|
def _collect_slot_fills_from_component_template(
|
||||||
template: Template,
|
template: Template,
|
||||||
fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]],
|
fill_content: Dict[str, FillContent],
|
||||||
slots_context: Optional[FilledSlotsContext],
|
|
||||||
registered_name: Optional[str],
|
registered_name: Optional[str],
|
||||||
) -> FilledSlotsContext:
|
) -> Dict[SlotName, Optional[FillContent]]:
|
||||||
if isinstance(fill_content, NodeList):
|
if DEFAULT_SLOT_KEY in fill_content:
|
||||||
default_fill_content = (fill_content, None)
|
named_fills_content = fill_content.copy()
|
||||||
named_fills_content = {}
|
default_fill_content = named_fills_content.pop(DEFAULT_SLOT_KEY)
|
||||||
else:
|
else:
|
||||||
|
named_fills_content = fill_content
|
||||||
default_fill_content = None
|
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.
|
# If value is `None`, then slot is unfilled.
|
||||||
slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {}
|
slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {}
|
||||||
|
@ -380,10 +403,11 @@ def _prepare_component_template_filled_slot_context(
|
||||||
required_slot_names: Set[str] = set()
|
required_slot_names: Set[str] = set()
|
||||||
|
|
||||||
# Collect fills and check for errors
|
# Collect fills and check for errors
|
||||||
for node in template.nodelist.get_nodes_by_type((SlotNode, IfSlotFilledConditionBranchNode)): # type: ignore
|
for node in template.nodelist.get_nodes_by_type(SlotNode):
|
||||||
if isinstance(node, SlotNode):
|
# Type check so the rest of the logic has type of `node` is inferred
|
||||||
# Give slot node knowledge of its parent template.
|
if not isinstance(node, SlotNode):
|
||||||
node.template = template
|
continue
|
||||||
|
|
||||||
slot_name = node.name
|
slot_name = node.name
|
||||||
if slot_name in slot_name2fill_content:
|
if slot_name in slot_name2fill_content:
|
||||||
raise TemplateSyntaxError(
|
raise TemplateSyntaxError(
|
||||||
|
@ -392,9 +416,10 @@ def _prepare_component_template_filled_slot_context(
|
||||||
f"To fix, check template '{template.name}' "
|
f"To fix, check template '{template.name}' "
|
||||||
f"of component '{registered_name}'."
|
f"of component '{registered_name}'."
|
||||||
)
|
)
|
||||||
content_data: Optional[FillContent] = None # `None` -> unfilled
|
|
||||||
if node.is_required:
|
if node.is_required:
|
||||||
required_slot_names.add(node.name)
|
required_slot_names.add(node.name)
|
||||||
|
|
||||||
|
content_data: Optional[FillContent] = None # `None` -> unfilled
|
||||||
if node.is_default:
|
if node.is_default:
|
||||||
if default_slot_encountered:
|
if default_slot_encountered:
|
||||||
raise TemplateSyntaxError(
|
raise TemplateSyntaxError(
|
||||||
|
@ -404,13 +429,13 @@ def _prepare_component_template_filled_slot_context(
|
||||||
)
|
)
|
||||||
content_data = default_fill_content
|
content_data = default_fill_content
|
||||||
default_slot_encountered = True
|
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:
|
if not content_data:
|
||||||
content_data = named_fills_content.get(node.name)
|
content_data = named_fills_content.get(node.name)
|
||||||
|
|
||||||
slot_name2fill_content[slot_name] = content_data
|
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.")
|
|
||||||
|
|
||||||
# Check: Only component templates that include a 'default' slot
|
# Check: Only component templates that include a 'default' slot
|
||||||
# can be invoked with implicit filling.
|
# can be invoked with implicit filling.
|
||||||
|
@ -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}
|
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()
|
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.
|
# Check that 'required' slots are filled.
|
||||||
for slot_name in unfilled_slots:
|
for slot_name in unfilled_slots:
|
||||||
if slot_name in required_slot_names:
|
if slot_name in required_slot_names:
|
||||||
|
@ -454,14 +490,3 @@ def _prepare_component_template_filled_slot_context(
|
||||||
if fuzzy_slot_name_matches:
|
if fuzzy_slot_name_matches:
|
||||||
msg += f"\nDid you mean '{fuzzy_slot_name_matches[0]}'?"
|
msg += f"\nDid you mean '{fuzzy_slot_name_matches[0]}'?"
|
||||||
raise TemplateSyntaxError(msg)
|
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)
|
|
||||||
|
|
|
@ -10,20 +10,22 @@ from django_components.app_settings import app_settings
|
||||||
from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
|
from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
|
||||||
from django_components.component_registry import ComponentRegistry
|
from django_components.component_registry import ComponentRegistry
|
||||||
from django_components.component_registry import registry as component_registry
|
from django_components.component_registry import registry as component_registry
|
||||||
|
from django_components.logger import trace_msg
|
||||||
from django_components.middleware import (
|
from django_components.middleware import (
|
||||||
CSS_DEPENDENCY_PLACEHOLDER,
|
CSS_DEPENDENCY_PLACEHOLDER,
|
||||||
JS_DEPENDENCY_PLACEHOLDER,
|
JS_DEPENDENCY_PLACEHOLDER,
|
||||||
is_dependency_middleware_active,
|
is_dependency_middleware_active,
|
||||||
)
|
)
|
||||||
from django_components.slots import (
|
from django_components.slots import (
|
||||||
|
FillNode,
|
||||||
IfSlotFilledConditionBranchNode,
|
IfSlotFilledConditionBranchNode,
|
||||||
IfSlotFilledElseBranchNode,
|
IfSlotFilledElseBranchNode,
|
||||||
IfSlotFilledNode,
|
IfSlotFilledNode,
|
||||||
NamedFillNode,
|
|
||||||
SlotNode,
|
SlotNode,
|
||||||
_IfSlotFilledBranchNode,
|
_IfSlotFilledBranchNode,
|
||||||
parse_slot_fill_nodes_from_component_nodelist,
|
parse_slot_fill_nodes_from_component_nodelist,
|
||||||
)
|
)
|
||||||
|
from django_components.utils import gen_id
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django_components.component import Component
|
from django_components.component import Component
|
||||||
|
@ -145,18 +147,27 @@ def do_slot(parser: Parser, token: Token) -> SlotNode:
|
||||||
"Order of options is free."
|
"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"])
|
nodelist = parser.parse(parse_until=["endslot"])
|
||||||
parser.delete_first_token()
|
parser.delete_first_token()
|
||||||
return SlotNode(
|
slot_node = SlotNode(
|
||||||
slot_name,
|
slot_name,
|
||||||
nodelist,
|
nodelist,
|
||||||
is_required=is_required,
|
is_required=is_required,
|
||||||
is_default=is_default,
|
is_default=is_default,
|
||||||
|
node_id=slot_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
trace_msg("PARSE", "SLOT", slot_name, slot_id, "...Done!")
|
||||||
|
return slot_node
|
||||||
|
|
||||||
|
|
||||||
@register.tag("fill")
|
@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
|
"""Block tag whose contents 'fill' (are inserted into) an identically named
|
||||||
'slot'-block in the component template referred to by a parent component.
|
'slot'-block in the component template referred to by a parent component.
|
||||||
It exists to make component nesting easier.
|
It exists to make component nesting easier.
|
||||||
|
@ -179,15 +190,25 @@ def do_fill(parser: Parser, token: Token) -> NamedFillNode:
|
||||||
alias_fexp = FilterExpression(alias, parser)
|
alias_fexp = FilterExpression(alias, parser)
|
||||||
else:
|
else:
|
||||||
raise TemplateSyntaxError(f"'{tag}' tag takes either 1 or 3 arguments: Received {len(args)}.")
|
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"])
|
nodelist = parser.parse(parse_until=["endfill"])
|
||||||
parser.delete_first_token()
|
parser.delete_first_token()
|
||||||
|
|
||||||
return NamedFillNode(
|
fill_node = FillNode(
|
||||||
nodelist,
|
nodelist,
|
||||||
name_fexp=FilterExpression(tgt_slot_name, tag),
|
name_fexp=FilterExpression(tgt_slot_name, tag),
|
||||||
alias_fexp=alias_fexp,
|
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")
|
@register.tag(name="component")
|
||||||
def do_component(parser: Parser, token: Token) -> ComponentNode:
|
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 = token.split_contents()
|
||||||
bits, isolated_context = check_for_isolated_context_keyword(bits)
|
bits, isolated_context = check_for_isolated_context_keyword(bits)
|
||||||
component_name, context_args, context_kwargs = parse_component_with_args(parser, bits, "component")
|
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"])
|
body: NodeList = parser.parse(parse_until=["endcomponent"])
|
||||||
parser.delete_first_token()
|
parser.delete_first_token()
|
||||||
fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode)
|
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(
|
component_node = ComponentNode(
|
||||||
FilterExpression(component_name, parser),
|
FilterExpression(component_name, parser),
|
||||||
context_args,
|
context_args,
|
||||||
context_kwargs,
|
context_kwargs,
|
||||||
isolated_context=isolated_context,
|
isolated_context=isolated_context,
|
||||||
fill_nodes=fill_nodes,
|
fill_nodes=fill_nodes,
|
||||||
|
component_id=component_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
trace_msg("PARSE", "COMP", component_name, component_id, "...Done!")
|
||||||
return component_node
|
return component_node
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -35,3 +35,17 @@ def search(search_glob: Optional[str] = None, engine: Optional[Engine] = None) -
|
||||||
component_filenames.append(Path(path))
|
component_filenames.append(Path(path))
|
||||||
|
|
||||||
return SearchResult(searched_dirs=dirs, matched_files=component_filenames)
|
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}"
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.template import Context, Template
|
from django.template import Context, Template
|
||||||
|
@ -204,6 +205,60 @@ class ComponentTest(SimpleTestCase):
|
||||||
|
|
||||||
self.assertIn('<input type="text" name="variable" value="hello">', rendered, rendered)
|
self.assertIn('<input type="text" name="variable" value="hello">', 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,
|
||||||
|
"""
|
||||||
|
<custom-template>
|
||||||
|
<header>Name: Jannete</header>
|
||||||
|
<main>Day: Monday</main>
|
||||||
|
<footer>
|
||||||
|
<custom-template>
|
||||||
|
<header>Name2: Jannete</header>
|
||||||
|
<main>Day2: Monday</main>
|
||||||
|
<footer>Default footer</footer>
|
||||||
|
</custom-template>
|
||||||
|
</footer>
|
||||||
|
</custom-template>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InlineComponentTest(SimpleTestCase):
|
class InlineComponentTest(SimpleTestCase):
|
||||||
def test_inline_html_component(self):
|
def test_inline_html_component(self):
|
||||||
|
@ -482,3 +537,153 @@ class ComponentIsolationTests(SimpleTestCase):
|
||||||
</custom-template>
|
</custom-template>
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
"""
|
||||||
|
<custom-template>
|
||||||
|
<header>Name: Igor</header>
|
||||||
|
<main>Day: Monday</main>
|
||||||
|
<footer>
|
||||||
|
<custom-template>
|
||||||
|
<header>Name2: Joe2</header>
|
||||||
|
<main>Day2: Monday</main>
|
||||||
|
<footer>Default footer</footer>
|
||||||
|
</custom-template>
|
||||||
|
</footer>
|
||||||
|
</custom-template>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
# {{ 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,
|
||||||
|
"""
|
||||||
|
<custom-template>
|
||||||
|
<header>Name: Jannete</header>
|
||||||
|
<main>Day: Monday</main>
|
||||||
|
<footer>
|
||||||
|
<custom-template>
|
||||||
|
<header>Name2: Jannete</header>
|
||||||
|
<main>Day2: Monday</main>
|
||||||
|
<footer>Default footer</footer>
|
||||||
|
</custom-template>
|
||||||
|
</footer>
|
||||||
|
</custom-template>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
# {{ name }} should be empty everywhere
|
||||||
|
rendered2 = self.template.render(Context({"day": "Monday"}))
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered2,
|
||||||
|
"""
|
||||||
|
<custom-template>
|
||||||
|
<header>Name: </header>
|
||||||
|
<main>Day: Monday</main>
|
||||||
|
<footer>
|
||||||
|
<custom-template>
|
||||||
|
<header>Name2: </header>
|
||||||
|
<main>Day2: Monday</main>
|
||||||
|
<footer>Default footer</footer>
|
||||||
|
</custom-template>
|
||||||
|
</footer>
|
||||||
|
</custom-template>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@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,
|
||||||
|
"""
|
||||||
|
<custom-template>
|
||||||
|
<header>Name: Jannete</header>
|
||||||
|
<main>Day: Monday</main>
|
||||||
|
<footer>
|
||||||
|
<custom-template>
|
||||||
|
<header>Name2: Jannete</header>
|
||||||
|
<main>Day2: Monday</main>
|
||||||
|
<footer>Default footer</footer>
|
||||||
|
</custom-template>
|
||||||
|
</footer>
|
||||||
|
</custom-template>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
# {{ name }} should be neither "Jannete" nor empty anywhere
|
||||||
|
rendered = self.template.render(Context({"day": "Monday"}))
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
<custom-template>
|
||||||
|
<header>Name: Igor</header>
|
||||||
|
<main>Day: Monday</main>
|
||||||
|
<footer>
|
||||||
|
<custom-template>
|
||||||
|
<header>Name2: Joe2</header>
|
||||||
|
<main>Day2: Monday</main>
|
||||||
|
<footer>Default footer</footer>
|
||||||
|
</custom-template>
|
||||||
|
</footer>
|
||||||
|
</custom-template>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
from unittest.mock import PropertyMock, patch
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
from django.template import Context, Template
|
from django.template import Context, Template
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
from django_components import component
|
from django_components import component
|
||||||
|
|
||||||
|
@ -65,7 +66,7 @@ class OuterContextComponent(component.Component):
|
||||||
template_name = "simple_template.html"
|
template_name = "simple_template.html"
|
||||||
|
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
return self.outer_context
|
return self.outer_context.flatten()
|
||||||
|
|
||||||
|
|
||||||
component.registry.register(name="parent_component", component=ParentComponent)
|
component.registry.register(name="parent_component", component=ParentComponent)
|
||||||
|
@ -385,7 +386,21 @@ class IsolatedContextSettingTests(SimpleTestCase):
|
||||||
|
|
||||||
|
|
||||||
class OuterContextPropertyTests(SimpleTestCase):
|
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(
|
template = Template(
|
||||||
"{% load component_tags %}{% component_dependencies %}"
|
"{% load component_tags %}{% component_dependencies %}"
|
||||||
"{% component 'outer_context_component' only %}{% endcomponent %}"
|
"{% component 'outer_context_component' only %}{% endcomponent %}"
|
||||||
|
|
|
@ -1320,8 +1320,8 @@ class IterationFillTest(SimpleTestCase):
|
||||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||||
|
|
||||||
objects = [
|
objects = [
|
||||||
{"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]},
|
{"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
|
||||||
{"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]},
|
{"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
|
||||||
]
|
]
|
||||||
|
|
||||||
template = Template(
|
template = Template(
|
||||||
|
@ -1343,10 +1343,10 @@ class IterationFillTest(SimpleTestCase):
|
||||||
self.assertHTMLEqual(
|
self.assertHTMLEqual(
|
||||||
rendered,
|
rendered,
|
||||||
"""
|
"""
|
||||||
OBJECT1_ITER1
|
ITER1_OBJ1
|
||||||
OBJECT2_ITER1
|
ITER1_OBJ2
|
||||||
OBJECT1_ITER2
|
ITER2_OBJ1
|
||||||
OBJECT2_ITER2
|
ITER2_OBJ2
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1354,8 +1354,8 @@ class IterationFillTest(SimpleTestCase):
|
||||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||||
|
|
||||||
objects = [
|
objects = [
|
||||||
{"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]},
|
{"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
|
||||||
{"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]},
|
{"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
|
||||||
]
|
]
|
||||||
|
|
||||||
template = Template(
|
template = Template(
|
||||||
|
@ -1389,14 +1389,14 @@ class IterationFillTest(SimpleTestCase):
|
||||||
"""
|
"""
|
||||||
OUTER_SCOPE_VARIABLE1
|
OUTER_SCOPE_VARIABLE1
|
||||||
OUTER_SCOPE_VARIABLE2
|
OUTER_SCOPE_VARIABLE2
|
||||||
OBJECT1_ITER1
|
ITER1_OBJ1
|
||||||
OUTER_SCOPE_VARIABLE2
|
OUTER_SCOPE_VARIABLE2
|
||||||
OBJECT2_ITER1
|
ITER1_OBJ2
|
||||||
OUTER_SCOPE_VARIABLE1
|
OUTER_SCOPE_VARIABLE1
|
||||||
OUTER_SCOPE_VARIABLE2
|
OUTER_SCOPE_VARIABLE2
|
||||||
OBJECT1_ITER2
|
ITER2_OBJ1
|
||||||
OUTER_SCOPE_VARIABLE2
|
OUTER_SCOPE_VARIABLE2
|
||||||
OBJECT2_ITER2
|
ITER2_OBJ2
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1404,8 +1404,8 @@ class IterationFillTest(SimpleTestCase):
|
||||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||||
|
|
||||||
objects = [
|
objects = [
|
||||||
{"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]},
|
{"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
|
||||||
{"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]},
|
{"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
|
||||||
]
|
]
|
||||||
|
|
||||||
template = Template(
|
template = Template(
|
||||||
|
@ -1427,10 +1427,10 @@ class IterationFillTest(SimpleTestCase):
|
||||||
self.assertHTMLEqual(
|
self.assertHTMLEqual(
|
||||||
rendered,
|
rendered,
|
||||||
"""
|
"""
|
||||||
OBJECT1_ITER1 default
|
ITER1_OBJ1 default
|
||||||
OBJECT2_ITER1 default
|
ITER1_OBJ2 default
|
||||||
OBJECT1_ITER2 default
|
ITER2_OBJ1 default
|
||||||
OBJECT2_ITER2 default
|
ITER2_OBJ2 default
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1440,8 +1440,8 @@ class IterationFillTest(SimpleTestCase):
|
||||||
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
component.registry.register("slot_in_a_loop", self.ComponentSimpleSlotInALoop)
|
||||||
|
|
||||||
objects = [
|
objects = [
|
||||||
{"inner": ["OBJECT1_ITER1", "OBJECT2_ITER1"]},
|
{"inner": ["ITER1_OBJ1", "ITER1_OBJ2"]},
|
||||||
{"inner": ["OBJECT1_ITER2", "OBJECT2_ITER2"]},
|
{"inner": ["ITER2_OBJ1", "ITER2_OBJ2"]},
|
||||||
]
|
]
|
||||||
|
|
||||||
template = Template(
|
template = Template(
|
||||||
|
@ -1475,13 +1475,13 @@ class IterationFillTest(SimpleTestCase):
|
||||||
"""
|
"""
|
||||||
OUTER_SCOPE_VARIABLE1
|
OUTER_SCOPE_VARIABLE1
|
||||||
OUTER_SCOPE_VARIABLE2
|
OUTER_SCOPE_VARIABLE2
|
||||||
OBJECT1_ITER1 default
|
ITER1_OBJ1 default
|
||||||
OUTER_SCOPE_VARIABLE2
|
OUTER_SCOPE_VARIABLE2
|
||||||
OBJECT2_ITER1 default
|
ITER1_OBJ2 default
|
||||||
OUTER_SCOPE_VARIABLE1
|
OUTER_SCOPE_VARIABLE1
|
||||||
OUTER_SCOPE_VARIABLE2
|
OUTER_SCOPE_VARIABLE2
|
||||||
OBJECT1_ITER2 default
|
ITER2_OBJ1 default
|
||||||
OUTER_SCOPE_VARIABLE2
|
OUTER_SCOPE_VARIABLE2
|
||||||
OBJECT2_ITER2 default
|
ITER2_OBJ2 default
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue