refactor: pass slot data and slot default to slot render fn and rename LazySlot to SlotRef

This commit is contained in:
Juro Oravec 2024-06-11 14:34:04 +02:00
parent 40f4476993
commit 5c89d4dbeb
4 changed files with 62 additions and 44 deletions

View file

@ -37,8 +37,17 @@ from django_components.context import (
from django_components.expression import safe_resolve_dict, safe_resolve_list from django_components.expression import safe_resolve_dict, safe_resolve_list
from django_components.logger import logger, trace_msg from django_components.logger import logger, trace_msg
from django_components.middleware import is_dependency_middleware_active from django_components.middleware import is_dependency_middleware_active
from django_components.node import RenderedContent, nodelist_to_render_func from django_components.slots import (
from django_components.slots import DEFAULT_SLOT_KEY, FillContent, FillNode, SlotContent, SlotName, resolve_slots DEFAULT_SLOT_KEY,
FillContent,
FillNode,
SlotContent,
SlotName,
SlotRef,
SlotRenderedContent,
_nodelist_to_slot_render_func,
resolve_slots,
)
from django_components.template_parser import process_aggregate_kwargs from django_components.template_parser import process_aggregate_kwargs
from django_components.utils import gen_id, search from django_components.utils import gen_id, search
@ -495,13 +504,13 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
slot_fills = {} slot_fills = {}
for slot_name, content in slots_data.items(): for slot_name, content in slots_data.items():
if isinstance(content, (str, SafeString)): if isinstance(content, (str, SafeString)):
content_func = nodelist_to_render_func( content_func = _nodelist_to_slot_render_func(
NodeList([TextNode(escape(content) if escape_content else content)]) NodeList([TextNode(escape(content) if escape_content else content)])
) )
else: else:
def content_func(ctx: Context) -> RenderedContent: def content_func(ctx: Context, kwargs: Dict[str, Any], slot_ref: SlotRef) -> SlotRenderedContent:
rendered = content(ctx) rendered = content(ctx, kwargs, slot_ref)
return escape(rendered) if escape_content else rendered return escape(rendered) if escape_content else rendered
slot_fills[slot_name] = FillContent( slot_fills[slot_name] = FillContent(
@ -555,7 +564,7 @@ class ComponentNode(Node):
if is_default_slot: if is_default_slot:
fill_content: Dict[str, FillContent] = { fill_content: Dict[str, FillContent] = {
DEFAULT_SLOT_KEY: FillContent( DEFAULT_SLOT_KEY: FillContent(
content_func=nodelist_to_render_func(self.fill_nodes[0].nodelist), content_func=_nodelist_to_slot_render_func(self.fill_nodes[0].nodelist),
slot_data_var=None, slot_data_var=None,
slot_default_var=None, slot_default_var=None,
), ),
@ -575,7 +584,7 @@ class ComponentNode(Node):
resolved_slot_default_var = fill_node.resolve_slot_default(context, resolved_component_name) resolved_slot_default_var = fill_node.resolve_slot_default(context, resolved_component_name)
resolved_slot_data_var = fill_node.resolve_slot_data(context, resolved_component_name) resolved_slot_data_var = fill_node.resolve_slot_data(context, resolved_component_name)
fill_content[resolved_name] = FillContent( fill_content[resolved_name] = FillContent(
content_func=nodelist_to_render_func(fill_node.nodelist), content_func=_nodelist_to_slot_render_func(fill_node.nodelist),
slot_default_var=resolved_slot_default_var, slot_default_var=resolved_slot_default_var,
slot_data_var=resolved_slot_data_var, slot_data_var=resolved_slot_data_var,
) )

View file

@ -1,20 +1,9 @@
from typing import Callable, List, NamedTuple, Optional, Union from typing import Callable, List, NamedTuple, Optional
from django.template import Context, Template from django.template import Context, Template
from django.template.base import Node, NodeList, TextNode from django.template.base import Node, NodeList, TextNode
from django.template.defaulttags import CommentNode from django.template.defaulttags import CommentNode
from django.template.loader_tags import ExtendsNode, IncludeNode, construct_relative_path from django.template.loader_tags import ExtendsNode, IncludeNode, construct_relative_path
from django.utils.safestring import SafeText
RenderedContent = Union[str, SafeText]
RenderFunc = Callable[[Context], RenderedContent]
def nodelist_to_render_func(nodelist: NodeList) -> RenderFunc:
def render_func(ctx: Context) -> RenderedContent:
return nodelist.render(ctx)
return render_func
def nodelist_has_content(nodelist: NodeList) -> bool: def nodelist_has_content(nodelist: NodeList) -> bool:

View file

@ -2,7 +2,7 @@ import difflib
import json import json
import re import re
from collections import deque from collections import deque
from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple, Type, Union from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, Type, Union
from django.template import Context, Template from django.template import Context, Template
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode
@ -14,25 +14,22 @@ from django_components.app_settings import ContextBehavior, app_settings
from django_components.context import _FILLED_SLOTS_CONTENT_CONTEXT_KEY, _ROOT_CTX_CONTEXT_KEY from django_components.context import _FILLED_SLOTS_CONTENT_CONTEXT_KEY, _ROOT_CTX_CONTEXT_KEY
from django_components.expression import resolve_expression_as_identifier, safe_resolve_dict from django_components.expression import resolve_expression_as_identifier, safe_resolve_dict
from django_components.logger import trace_msg from django_components.logger import trace_msg
from django_components.node import ( from django_components.node import NodeTraverse, nodelist_has_content, walk_nodelist
NodeTraverse,
RenderFunc,
nodelist_has_content,
nodelist_to_render_func,
walk_nodelist,
)
from django_components.template_parser import process_aggregate_kwargs from django_components.template_parser import process_aggregate_kwargs
from django_components.utils import gen_id from django_components.utils import gen_id
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT" DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
SlotRenderedContent = Union[str, SafeString]
SlotRenderFunc = Callable[[Context, Dict[str, Any], "SlotRef"], SlotRenderedContent]
# Type aliases # Type aliases
SlotId = str SlotId = str
SlotName = str SlotName = str
SlotDefaultName = str SlotDefaultName = str
SlotDataName = str SlotDataName = str
SlotContent = Union[str, SafeString, RenderFunc] SlotContent = Union[str, SafeString, SlotRenderFunc]
class FillContent(NamedTuple): class FillContent(NamedTuple):
@ -50,7 +47,7 @@ class FillContent(NamedTuple):
``` ```
""" """
content_func: RenderFunc content_func: SlotRenderFunc
slot_default_var: Optional[SlotDefaultName] slot_default_var: Optional[SlotDefaultName]
slot_data_var: Optional[SlotDataName] slot_data_var: Optional[SlotDataName]
@ -85,18 +82,18 @@ class SlotFill(NamedTuple):
name: str name: str
escaped_name: str escaped_name: str
is_filled: bool is_filled: bool
content_func: RenderFunc content_func: SlotRenderFunc
context_data: Dict context_data: Dict
slot_default_var: Optional[SlotDefaultName] slot_default_var: Optional[SlotDefaultName]
slot_data_var: Optional[SlotDataName] slot_data_var: Optional[SlotDataName]
class LazySlot: class SlotRef:
""" """
Bound a slot and a context, so we can move these around as a single variable, SlotRef allows to treat a slot as a variable. The slot is rendered only once
and lazily render the slot with given context only once coerced to string. the instance is coerced to string.
This is used to access slots as variables inside the templates. When a LazySlot This is used to access slots as variables inside the templates. When a SlotRef
is rendered in the template with `{{ my_lazy_slot }}`, it will output the contents is rendered in the template with `{{ my_lazy_slot }}`, it will output the contents
of the slot. of the slot.
""" """
@ -105,7 +102,7 @@ class LazySlot:
self._slot = slot self._slot = slot
self._context = context self._context = context
# Render the slot when the template coerces LazySlot to string # Render the slot when the template coerces SlotRef to string
def __str__(self) -> str: def __str__(self) -> str:
return mark_safe(self._slot.nodelist.render(self._context)) return mark_safe(self._slot.nodelist.render(self._context))
@ -149,25 +146,26 @@ class SlotNode(Node):
# If slot fill is using `{% fill "myslot" default="abc" %}`, then set the "abc" to # If slot fill is using `{% fill "myslot" default="abc" %}`, then set the "abc" to
# the context, so users can refer to the default slot from within the fill content. # the context, so users can refer to the default slot from within the fill content.
slot_ref = SlotRef(self, context)
default_var = slot_fill.slot_default_var default_var = slot_fill.slot_default_var
if default_var: if default_var:
if not default_var.isidentifier(): if not default_var.isidentifier():
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Slot default alias in fill '{self.name}' must be a valid identifier. Got '{default_var}'" f"Slot default alias in fill '{self.name}' must be a valid identifier. Got '{default_var}'"
) )
extra_context[default_var] = LazySlot(self, context) extra_context[default_var] = slot_ref
# Expose the kwargs that were passed to the `{% slot %}` tag. These kwargs # Expose the kwargs that were passed to the `{% slot %}` tag. These kwargs
# are made available through a variable name that was set on the `{% fill %}` # are made available through a variable name that was set on the `{% fill %}`
# tag. # tag.
slot_kwargs = safe_resolve_dict(self.slot_kwargs, context)
slot_kwargs = process_aggregate_kwargs(slot_kwargs)
data_var = slot_fill.slot_data_var data_var = slot_fill.slot_data_var
if data_var: if data_var:
if not data_var.isidentifier(): if not data_var.isidentifier():
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Slot data alias in fill '{self.name}' must be a valid identifier. Got '{data_var}'" f"Slot data alias in fill '{self.name}' must be a valid identifier. Got '{data_var}'"
) )
slot_kwargs = safe_resolve_dict(self.slot_kwargs, context)
slot_kwargs = process_aggregate_kwargs(slot_kwargs)
extra_context[data_var] = slot_kwargs extra_context[data_var] = slot_kwargs
# For the user-provided slot fill, we want to use the context of where the slot # For the user-provided slot fill, we want to use the context of where the slot
@ -175,7 +173,9 @@ class SlotNode(Node):
used_ctx = self._resolve_slot_context(context, slot_fill) used_ctx = self._resolve_slot_context(context, slot_fill)
with used_ctx.update(extra_context): with used_ctx.update(extra_context):
# Render slot as a function # Render slot as a function
output = slot_fill.content_func(used_ctx) # NOTE: While `{% fill %}` tag has to opt in for the `default` and `data` variables,
# the render function ALWAYS receives them.
output = slot_fill.content_func(used_ctx, slot_kwargs, slot_ref)
trace_msg("RENDR", "SLOT", self.name, self.node_id, msg="...Done!") trace_msg("RENDR", "SLOT", self.name, self.node_id, msg="...Done!")
return output return output
@ -465,7 +465,7 @@ def resolve_slots(
name=slot.name, name=slot.name,
escaped_name=_escape_slot_name(slot.name), escaped_name=_escape_slot_name(slot.name),
is_filled=False, is_filled=False,
content_func=nodelist_to_render_func(slot.nodelist), content_func=_nodelist_to_slot_render_func(slot.nodelist),
context_data=context_data, context_data=context_data,
slot_default_var=None, slot_default_var=None,
slot_data_var=None, slot_data_var=None,
@ -595,3 +595,10 @@ def _escape_slot_name(name: str) -> str:
# we leave this obligation to the user. # we leave this obligation to the user.
escaped_name = name_escape_re.sub("_", name) escaped_name = name_escape_re.sub("_", name)
return escaped_name return escaped_name
def _nodelist_to_slot_render_func(nodelist: NodeList) -> SlotRenderFunc:
def render_func(ctx: Context, slot_kwargs: Dict[str, Any], slot_ref: SlotRef) -> SlotRenderedContent:
return nodelist.render(ctx)
return render_func

View file

@ -3,6 +3,8 @@ Tests focusing on the Component class.
For tests focusing on the `component` tag, see `test_templatetags_component.py` For tests focusing on the `component` tag, see `test_templatetags_component.py`
""" """
from typing import Dict
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponse from django.http import HttpResponse
from django.template import Context, Template, TemplateSyntaxError from django.template import Context, Template, TemplateSyntaxError
@ -14,6 +16,7 @@ from .testutils import BaseTestCase, parametrize_context_behavior
# isort: on # isort: on
from django_components import component, types from django_components import component, types
from django_components.slots import SlotRef
class ComponentTest(BaseTestCase): class ComponentTest(BaseTestCase):
@ -360,7 +363,8 @@ class ComponentRenderTest(BaseTestCase):
class SimpleComponent(component.Component): class SimpleComponent(component.Component):
template: types.django_html = """ template: types.django_html = """
{% load component_tags %} {% load component_tags %}
{% slot "first" required %} {% slot "first" required data1="abc" data2:hello="world" data2:one=123 %}
SLOT_DEFAULT
{% endslot %} {% endslot %}
""" """
@ -371,7 +375,7 @@ class ComponentRenderTest(BaseTestCase):
"kwargs": kwargs, "kwargs": kwargs,
} }
def first_slot(ctx: Context): def first_slot(ctx: Context, slot_data: Dict, slot_ref: SlotRef):
self.assertIsInstance(ctx, Context) self.assertIsInstance(ctx, Context)
# NOTE: Since the slot has access to the Context object, it should behave # NOTE: Since the slot has access to the Context object, it should behave
# the same way as it does in templates - when in "isolated" mode, then the # the same way as it does in templates - when in "isolated" mode, then the
@ -388,7 +392,16 @@ class ComponentRenderTest(BaseTestCase):
self.assertEqual(ctx["kwargs"], {}) self.assertEqual(ctx["kwargs"], {})
self.assertEqual(ctx["abc"], "def") self.assertEqual(ctx["abc"], "def")
return "FROM_INSIDE_FIRST_SLOT" slot_data_expected = {
"data1": "abc",
"data2": {"hello": "world", "one": 123},
}
self.assertDictEqual(slot_data_expected, slot_data)
self.assertIsInstance(slot_ref, SlotRef)
self.assertEqual("SLOT_DEFAULT", str(slot_ref).strip())
return f"FROM_INSIDE_FIRST_SLOT | {slot_ref}"
rendered = SimpleComponent.render( rendered = SimpleComponent.render(
context={"abc": "def"}, context={"abc": "def"},
@ -398,7 +411,7 @@ class ComponentRenderTest(BaseTestCase):
) )
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
"FROM_INSIDE_FIRST_SLOT", "FROM_INSIDE_FIRST_SLOT | SLOT_DEFAULT",
) )
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])