mirror of
https://github.com/django-components/django-components.git
synced 2025-09-20 04:39:45 +00:00
refactor: fix component recursion (#936)
This commit is contained in:
parent
e105500350
commit
588053803d
25 changed files with 1549 additions and 464 deletions
31
sampleproject/components/recursive.py
Normal file
31
sampleproject/components/recursive.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
from typing import Any, Dict
|
||||
|
||||
from django_components import Component, register, types
|
||||
|
||||
|
||||
@register("recursive")
|
||||
class Recursive(Component):
|
||||
def get(self, request):
|
||||
import time
|
||||
time_before = time.time()
|
||||
output = self.render_to_response(
|
||||
kwargs={
|
||||
"depth": 0,
|
||||
},
|
||||
)
|
||||
time_after = time.time()
|
||||
print("TIME: ", time_after - time_before)
|
||||
return output
|
||||
|
||||
def get_context_data(self, depth: int = 0) -> Dict[str, Any]:
|
||||
return {"depth": depth + 1}
|
||||
|
||||
template: types.django_html = """
|
||||
<div id="recursive">
|
||||
depth: {{ depth }}
|
||||
<hr/>
|
||||
{% if depth <= 100 %}
|
||||
{% component "recursive" depth=depth / %}
|
||||
{% endif %}
|
||||
</div>
|
||||
"""
|
|
@ -2,6 +2,7 @@ from components.calendar.calendar import Calendar, CalendarRelative
|
|||
from components.fragment import FragAlpine, FragJs, FragmentBaseAlpine, FragmentBaseHtmx, FragmentBaseJs
|
||||
from components.greeting import Greeting
|
||||
from components.nested.calendar.calendar import CalendarNested
|
||||
from components.recursive import Recursive
|
||||
from django.urls import path
|
||||
|
||||
urlpatterns = [
|
||||
|
@ -9,6 +10,7 @@ urlpatterns = [
|
|||
path("calendar/", Calendar.as_view(), name="calendar"),
|
||||
path("calendar-relative/", CalendarRelative.as_view(), name="calendar-relative"),
|
||||
path("calendar-nested/", CalendarNested.as_view(), name="calendar-nested"),
|
||||
path("recursive/", Recursive.as_view(), name="recursive"),
|
||||
path("fragment/base/alpine", FragmentBaseAlpine.as_view()),
|
||||
path("fragment/base/htmx", FragmentBaseHtmx.as_view()),
|
||||
path("fragment/base/js", FragmentBaseJs.as_view()),
|
||||
|
|
|
@ -26,10 +26,10 @@ from typing import (
|
|||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.forms.widgets import Media as MediaCls
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.template.base import NodeList, Parser, Template, TextNode, Token
|
||||
from django.template.base import NodeList, Origin, Parser, Template, TextNode, Token
|
||||
from django.template.context import Context, RequestContext
|
||||
from django.template.loader import get_template
|
||||
from django.template.loader_tags import BLOCK_CONTEXT_KEY
|
||||
from django.template.loader_tags import BLOCK_CONTEXT_KEY, BlockContext
|
||||
from django.test.signals import template_rendered
|
||||
from django.utils.html import conditional_escape
|
||||
from django.views import View
|
||||
|
@ -38,13 +38,7 @@ from django_components.app_settings import ContextBehavior
|
|||
from django_components.component_media import ComponentMediaInput, ComponentMediaMeta
|
||||
from django_components.component_registry import ComponentRegistry
|
||||
from django_components.component_registry import registry as registry_
|
||||
from django_components.context import (
|
||||
_COMPONENT_SLOT_CTX_CONTEXT_KEY,
|
||||
_REGISTRY_CONTEXT_KEY,
|
||||
_ROOT_CTX_CONTEXT_KEY,
|
||||
get_injected_context_var,
|
||||
make_isolated_context_copy,
|
||||
)
|
||||
from django_components.context import _COMPONENT_CONTEXT_KEY, make_isolated_context_copy
|
||||
from django_components.dependencies import (
|
||||
RenderType,
|
||||
cache_component_css,
|
||||
|
@ -52,13 +46,17 @@ from django_components.dependencies import (
|
|||
cache_component_js,
|
||||
cache_component_js_vars,
|
||||
comp_hash_mapping,
|
||||
postprocess_component_html,
|
||||
insert_component_dependencies_comment,
|
||||
)
|
||||
from django_components.dependencies import render_dependencies as _render_dependencies
|
||||
from django_components.dependencies import (
|
||||
set_component_attrs_for_js_and_css,
|
||||
)
|
||||
from django_components.node import BaseNode
|
||||
from django_components.perfutil.component import component_post_render
|
||||
from django_components.perfutil.component import ComponentRenderer, component_context_cache, component_post_render
|
||||
from django_components.perfutil.provide import register_provide_reference, unregister_provide_reference
|
||||
from django_components.provide import get_injected_context_var
|
||||
from django_components.slots import (
|
||||
ComponentSlotContext,
|
||||
Slot,
|
||||
SlotContent,
|
||||
SlotFunc,
|
||||
|
@ -71,8 +69,11 @@ from django_components.slots import (
|
|||
resolve_fills,
|
||||
)
|
||||
from django_components.template import cached_template
|
||||
from django_components.util.context import snapshot_context
|
||||
from django_components.util.django_monkeypatch import is_template_cls_patched
|
||||
from django_components.util.misc import gen_id, hash_comp_cls
|
||||
from django_components.util.exception import component_error_message
|
||||
from django_components.util.logger import trace_component_msg
|
||||
from django_components.util.misc import gen_id, get_import_path, hash_comp_cls
|
||||
from django_components.util.template_tag import TagAttr
|
||||
from django_components.util.validation import validate_typed_dict, validate_typed_tuple
|
||||
|
||||
|
@ -99,7 +100,6 @@ CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any])
|
|||
|
||||
@dataclass(frozen=True)
|
||||
class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
|
||||
id: str
|
||||
context: Context
|
||||
args: ArgsType
|
||||
kwargs: KwargsType
|
||||
|
@ -109,7 +109,8 @@ class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
|
|||
|
||||
|
||||
@dataclass()
|
||||
class RenderStackItem(Generic[ArgsType, KwargsType, SlotsType]):
|
||||
class MetadataItem(Generic[ArgsType, KwargsType, SlotsType]):
|
||||
render_id: str
|
||||
input: RenderInput[ArgsType, KwargsType, SlotsType]
|
||||
is_filled: Optional[SlotIsFilled]
|
||||
|
||||
|
@ -218,6 +219,21 @@ class ComponentView(View, metaclass=ComponentViewMeta):
|
|||
self.component = component
|
||||
|
||||
|
||||
# Internal data that are made available within the component's template
|
||||
@dataclass
|
||||
class ComponentContext:
|
||||
component_name: str
|
||||
component_id: str
|
||||
component_class: Type["Component"]
|
||||
component_path: List[str]
|
||||
template_name: Optional[str]
|
||||
is_dynamic_component: bool
|
||||
default_slot: Optional[str]
|
||||
fills: Dict[SlotName, Slot]
|
||||
outer_context: Optional[Context]
|
||||
registry: ComponentRegistry
|
||||
|
||||
|
||||
class Component(
|
||||
Generic[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType],
|
||||
metaclass=ComponentMeta,
|
||||
|
@ -580,9 +596,9 @@ class Component(
|
|||
self.as_view = types.MethodType(self.__class__.as_view.__func__, self) # type: ignore
|
||||
|
||||
self.registered_name: Optional[str] = registered_name
|
||||
self.outer_context: Context = outer_context or Context()
|
||||
self.outer_context: Optional[Context] = outer_context
|
||||
self.registry = registry or registry_
|
||||
self._render_stack: Deque[RenderStackItem[ArgsType, KwargsType, SlotsType]] = deque()
|
||||
self._metadata_stack: Deque[MetadataItem[ArgsType, KwargsType, SlotsType]] = deque()
|
||||
# None == uninitialized, False == No types, Tuple == types
|
||||
self._types: Optional[Union[Tuple[Any, Any, Any, Any, Any, Any], Literal[False]]] = None
|
||||
|
||||
|
@ -590,6 +606,12 @@ class Component(
|
|||
cls._class_hash = hash_comp_cls(cls)
|
||||
comp_hash_mapping[cls._class_hash] = cls
|
||||
|
||||
@contextmanager
|
||||
def _with_metadata(self, item: MetadataItem) -> Generator[None, None, None]:
|
||||
self._metadata_stack.append(item)
|
||||
yield
|
||||
self._metadata_stack.pop()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.registered_name or self.__class__.__name__
|
||||
|
@ -623,7 +645,13 @@ class Component(
|
|||
# Rendering 'ab3c4d'
|
||||
```
|
||||
"""
|
||||
return self.input.id
|
||||
if not len(self._metadata_stack):
|
||||
raise RuntimeError(
|
||||
f"{self.name}: Tried to access Component's `id` attribute " "while outside of rendering execution"
|
||||
)
|
||||
|
||||
ctx = self._metadata_stack[-1]
|
||||
return ctx.render_id
|
||||
|
||||
@property
|
||||
def input(self) -> RenderInput[ArgsType, KwargsType, SlotsType]:
|
||||
|
@ -631,12 +659,12 @@ class Component(
|
|||
Input holds the data (like arg, kwargs, slots) that were passed to
|
||||
the current execution of the `render` method.
|
||||
"""
|
||||
if not len(self._render_stack):
|
||||
if not len(self._metadata_stack):
|
||||
raise RuntimeError(f"{self.name}: Tried to access Component input while outside of rendering execution")
|
||||
|
||||
# NOTE: Input is managed as a stack, so if `render` is called within another `render`,
|
||||
# the propertes below will return only the inner-most state.
|
||||
return self._render_stack[-1].input
|
||||
return self._metadata_stack[-1].input
|
||||
|
||||
@property
|
||||
def is_filled(self) -> SlotIsFilled:
|
||||
|
@ -646,13 +674,13 @@ class Component(
|
|||
This attribute is available for use only within the template as `{{ component_vars.is_filled.slot_name }}`,
|
||||
and within `on_render_before` and `on_render_after` hooks.
|
||||
"""
|
||||
if not len(self._render_stack):
|
||||
if not len(self._metadata_stack):
|
||||
raise RuntimeError(
|
||||
f"{self.name}: Tried to access Component's `is_filled` attribute "
|
||||
"while outside of rendering execution"
|
||||
)
|
||||
|
||||
ctx = self._render_stack[-1]
|
||||
ctx = self._metadata_stack[-1]
|
||||
if ctx.is_filled is None:
|
||||
raise RuntimeError(
|
||||
f"{self.name}: Tried to access Component's `is_filled` attribute " "before slots were resolved"
|
||||
|
@ -666,7 +694,7 @@ class Component(
|
|||
#
|
||||
# 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: Context) -> Template:
|
||||
def _get_template(self, context: Context, component_id: str) -> Template:
|
||||
template_name = self.get_template_name(context)
|
||||
# TODO_REMOVE_IN_V1 - Remove `self.get_template_string` in v1
|
||||
template_getter = getattr(self, "get_template_string", self.get_template)
|
||||
|
@ -700,9 +728,11 @@ class Component(
|
|||
if template_body is not None:
|
||||
# We got template string, so we convert it to Template
|
||||
if isinstance(template_body, str):
|
||||
trace_component_msg("COMP_LOAD", component_name=self.name, component_id=component_id, slot_name=None)
|
||||
template: Template = cached_template(
|
||||
template_string=template_body,
|
||||
name=self.template_file,
|
||||
name=self.template_file or self.name,
|
||||
origin=Origin(name=self.template_file or get_import_path(self.__class__)),
|
||||
)
|
||||
else:
|
||||
template = template_body
|
||||
|
@ -929,36 +959,14 @@ class Component(
|
|||
render_dependencies: bool = True,
|
||||
request: Optional[HttpRequest] = None,
|
||||
) -> str:
|
||||
try:
|
||||
return self._render_impl(
|
||||
context, args, kwargs, slots, escape_slots_content, type, render_dependencies, request
|
||||
)
|
||||
except Exception as err:
|
||||
# Nicely format the error message to include the component path.
|
||||
# E.g.
|
||||
# ```
|
||||
# KeyError: "An error occured while rendering components ProjectPage > ProjectLayoutTabbed >
|
||||
# Layout > RenderContextProvider > Base > TabItem:
|
||||
# Component 'TabItem' tried to inject a variable '_tab' before it was provided.
|
||||
# ```
|
||||
|
||||
if not hasattr(err, "_components"):
|
||||
err._components = [] # type: ignore[attr-defined]
|
||||
|
||||
components = getattr(err, "_components", [])
|
||||
|
||||
# Access the exception's message, see https://stackoverflow.com/a/75549200/9788634
|
||||
if not components:
|
||||
orig_msg = err.args[0]
|
||||
else:
|
||||
orig_msg = err.args[0].split("\n", 1)[1]
|
||||
|
||||
components.insert(0, self.name)
|
||||
comp_path = " > ".join(components)
|
||||
prefix = f"An error occured while rendering components {comp_path}:\n"
|
||||
|
||||
err.args = (prefix + orig_msg,) # tuple of one
|
||||
raise err
|
||||
# Modify the error to display full component path (incl. slots)
|
||||
with component_error_message([self.name]):
|
||||
try:
|
||||
return self._render_impl(
|
||||
context, args, kwargs, slots, escape_slots_content, type, render_dependencies, request
|
||||
)
|
||||
except Exception as err:
|
||||
raise err from None
|
||||
|
||||
def _render_impl(
|
||||
self,
|
||||
|
@ -990,28 +998,84 @@ class Component(
|
|||
|
||||
# Required for compatibility with Django's {% extends %} tag
|
||||
# See https://github.com/django-components/django-components/pull/859
|
||||
context.render_context.push({BLOCK_CONTEXT_KEY: context.render_context.get(BLOCK_CONTEXT_KEY, {})})
|
||||
context.render_context.push({BLOCK_CONTEXT_KEY: context.render_context.get(BLOCK_CONTEXT_KEY, BlockContext())})
|
||||
|
||||
# By adding the current input to the stack, we temporarily allow users
|
||||
# to access the provided context, slots, etc. Also required so users can
|
||||
# call `self.inject()` from within `get_context_data()`.
|
||||
#
|
||||
# This is handled as a stack, as users can potentially call `component.render()`
|
||||
# from within component hooks. Thus, then they do so, `component.id` will be the ID
|
||||
# of the deepest-most call to `component.render()`.
|
||||
render_id = gen_id()
|
||||
self._render_stack.append(
|
||||
RenderStackItem(
|
||||
input=RenderInput(
|
||||
id=render_id,
|
||||
context=context,
|
||||
slots=slots,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
type=type,
|
||||
render_dependencies=render_dependencies,
|
||||
),
|
||||
is_filled=None,
|
||||
metadata = MetadataItem(
|
||||
render_id=render_id,
|
||||
input=RenderInput(
|
||||
context=context,
|
||||
slots=slots,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
type=type,
|
||||
render_dependencies=render_dependencies,
|
||||
),
|
||||
is_filled=None,
|
||||
)
|
||||
|
||||
context_data = self.get_context_data(*args, **kwargs)
|
||||
# We pass down the components the info about the component's parent.
|
||||
# This is used for correctly resolving slot fills, correct rendering order,
|
||||
# or CSS scoping.
|
||||
if context.get(_COMPONENT_CONTEXT_KEY, None):
|
||||
parent_id = cast(str, context[_COMPONENT_CONTEXT_KEY])
|
||||
parent_comp_ctx = component_context_cache[parent_id]
|
||||
component_path = [*parent_comp_ctx.component_path, self.name]
|
||||
else:
|
||||
parent_id = None
|
||||
component_path = [self.name]
|
||||
|
||||
trace_component_msg(
|
||||
"COMP_PREP_START",
|
||||
component_name=self.name,
|
||||
component_id=render_id,
|
||||
slot_name=None,
|
||||
component_path=component_path,
|
||||
extra=f"Received {len(args)} args, {len(kwargs)} kwargs, {len(slots)} slots",
|
||||
)
|
||||
|
||||
# Register the component to provide
|
||||
register_provide_reference(context, render_id)
|
||||
|
||||
# This is data that will be accessible (internally) from within the component's template
|
||||
component_ctx = ComponentContext(
|
||||
component_class=self.__class__,
|
||||
component_name=self.name,
|
||||
component_id=render_id,
|
||||
component_path=component_path,
|
||||
# Template name is set only once we've resolved the component's Template instance.
|
||||
template_name=None,
|
||||
fills=slots_untyped,
|
||||
is_dynamic_component=getattr(self, "_is_dynamic_component", False),
|
||||
# This field will be modified from within `SlotNodes.render()`:
|
||||
# - The `default_slot` will be set to the first slot that has the `default` attribute set.
|
||||
# If multiple slots have the `default` attribute set, yet have different name, then
|
||||
# we will raise an error.
|
||||
default_slot=None,
|
||||
outer_context=snapshot_context(self.outer_context) if self.outer_context is not None else None,
|
||||
registry=self.registry,
|
||||
)
|
||||
|
||||
# Instead of passing the ComponentContext directly through the Context, the entry on the Context
|
||||
# contains only a key to retrieve the ComponentContext from `component_context_cache`.
|
||||
#
|
||||
# This way, the flow is easier to debug. Because otherwise, if you try to print out
|
||||
# or inspect the Context object, your screen is filled with the deeply nested ComponentContext objects.
|
||||
component_context_cache[render_id] = component_ctx
|
||||
|
||||
# Allow to access component input and metadata like component ID from within these hook
|
||||
with self._with_metadata(metadata):
|
||||
context_data = self.get_context_data(*args, **kwargs)
|
||||
# TODO - enable JS and CSS vars
|
||||
# js_data = self.get_js_data(*args, **kwargs)
|
||||
# css_data = self.get_css_data(*args, **kwargs)
|
||||
self._validate_outputs(data=context_data)
|
||||
|
||||
# Process Component's JS and CSS
|
||||
|
@ -1023,40 +1087,19 @@ class Component(
|
|||
cache_component_css(self.__class__)
|
||||
css_input_hash = cache_component_css_vars(self.__class__, css_data) if css_data else None
|
||||
|
||||
with _prepare_template(self, context, context_data) as template:
|
||||
with _prepare_template(self, context, context_data, metadata) as template:
|
||||
component_ctx.template_name = template.name
|
||||
|
||||
# For users, we expose boolean variables that they may check
|
||||
# to see if given slot was filled, e.g.:
|
||||
# `{% if variable > 8 and component_vars.is_filled.header %}`
|
||||
is_filled = SlotIsFilled(slots_untyped)
|
||||
self._render_stack[-1].is_filled = is_filled
|
||||
|
||||
# If any slot fills were defined within the template, we want to scope them
|
||||
# to the CSS of the parent component. Thus we keep track of the parent component.
|
||||
if context.get(_COMPONENT_SLOT_CTX_CONTEXT_KEY, None):
|
||||
parent_comp_ctx: ComponentSlotContext = context[_COMPONENT_SLOT_CTX_CONTEXT_KEY]
|
||||
parent_id = parent_comp_ctx.component_id
|
||||
else:
|
||||
parent_id = None
|
||||
|
||||
component_slot_ctx = ComponentSlotContext(
|
||||
component_name=self.name,
|
||||
component_id=render_id,
|
||||
template_name=template.name,
|
||||
fills=slots_untyped,
|
||||
is_dynamic_component=getattr(self, "_is_dynamic_component", False),
|
||||
# This field will be modified from within `SlotNodes.render()`:
|
||||
# - The `default_slot` will be set to the first slot that has the `default` attribute set.
|
||||
# If multiple slots have the `default` attribute set, yet have different name, then
|
||||
# we will raise an error.
|
||||
default_slot=None,
|
||||
)
|
||||
metadata.is_filled = is_filled
|
||||
|
||||
with context.update(
|
||||
{
|
||||
# Private context fields
|
||||
_ROOT_CTX_CONTEXT_KEY: self.outer_context,
|
||||
_COMPONENT_SLOT_CTX_CONTEXT_KEY: component_slot_ctx,
|
||||
_REGISTRY_CONTEXT_KEY: self.registry,
|
||||
_COMPONENT_CONTEXT_KEY: render_id,
|
||||
# NOTE: Public API for variables accessible from within a component's template
|
||||
# See https://github.com/django-components/django-components/issues/280#issuecomment-2081180940
|
||||
"component_vars": ComponentVars(
|
||||
|
@ -1064,60 +1107,149 @@ class Component(
|
|||
),
|
||||
}
|
||||
):
|
||||
self.on_render_before(context, template)
|
||||
# Make a "snapshot" of the context as it was at the time of the render call.
|
||||
#
|
||||
# Previously, we recursively called `Template.render()` as this point, but due to recursion
|
||||
# this was limiting the number of nested components to only about 60 levels deep.
|
||||
#
|
||||
# Now, we make a flat copy, so that the context copy is static and doesn't change even if
|
||||
# we leave the `with context.update` blocks.
|
||||
#
|
||||
# This makes it possible to render nested components with a queue, avoiding recursion limits.
|
||||
context_snapshot = snapshot_context(context)
|
||||
|
||||
# Cleanup
|
||||
context.render_context.pop()
|
||||
|
||||
# Instead of rendering component at the time we come across the `{% component %}` tag
|
||||
# in the template, we defer rendering in order to scalably handle deeply nested components.
|
||||
#
|
||||
# See `_gen_component_renderer()` for more details.
|
||||
deferred_render = self._gen_component_renderer(
|
||||
render_id=render_id,
|
||||
template=template,
|
||||
context=context_snapshot,
|
||||
metadata=metadata,
|
||||
component_path=component_path,
|
||||
css_input_hash=css_input_hash,
|
||||
js_input_hash=js_input_hash,
|
||||
css_scope_id=None, # TODO - Implement CSS scoping
|
||||
)
|
||||
|
||||
# Remove component from caches
|
||||
def on_component_rendered(component_id: str) -> None:
|
||||
del component_context_cache[component_id] # type: ignore[arg-type]
|
||||
unregister_provide_reference(component_id) # type: ignore[arg-type]
|
||||
|
||||
# After the component and all its children are rendered, we resolve
|
||||
# all inserted HTML comments into <script> and <link> tags (if render_dependencies=True)
|
||||
def on_html_rendered(html: str) -> str:
|
||||
if render_dependencies:
|
||||
html = _render_dependencies(html, type)
|
||||
return html
|
||||
|
||||
trace_component_msg(
|
||||
"COMP_PREP_END",
|
||||
component_name=self.name,
|
||||
component_id=render_id,
|
||||
slot_name=None,
|
||||
component_path=component_path,
|
||||
)
|
||||
|
||||
return component_post_render(
|
||||
renderer=deferred_render,
|
||||
render_id=render_id,
|
||||
component_name=self.name,
|
||||
parent_id=parent_id,
|
||||
on_component_rendered=on_component_rendered,
|
||||
on_html_rendered=on_html_rendered,
|
||||
)
|
||||
|
||||
# Creates a renderer function that will be called only once, when the component is to be rendered.
|
||||
#
|
||||
# By encapsulating components' output as render function, we can render components top-down,
|
||||
# starting from root component, and moving down.
|
||||
#
|
||||
# This way, when it comes to rendering a particular component, we have already rendered its parent,
|
||||
# and we KNOW if there were any HTML attributes that were passed from parent to children.
|
||||
#
|
||||
# Thus, the returned renderer function accepts the extra HTML attributes that were passed from parent,
|
||||
# and returns the updated HTML content.
|
||||
#
|
||||
# Because the HTML attributes are all boolean (e.g. `data-djc-id-a1b3c4`), they are passed as a list.
|
||||
#
|
||||
# This whole setup makes it possible for multiple components to resolve to the same HTML element.
|
||||
# E.g. if CompA renders CompB, and CompB renders a <div>, then the <div> element will have
|
||||
# IDs of both CompA and CompB.
|
||||
# ```html
|
||||
# <div djc-id-a1b3cf djc-id-f3d3cf>...</div>
|
||||
# ```
|
||||
def _gen_component_renderer(
|
||||
self,
|
||||
render_id: str,
|
||||
template: Template,
|
||||
context: Context,
|
||||
metadata: MetadataItem,
|
||||
component_path: List[str],
|
||||
css_input_hash: Optional[str],
|
||||
js_input_hash: Optional[str],
|
||||
css_scope_id: Optional[str],
|
||||
) -> ComponentRenderer:
|
||||
component = self
|
||||
component_name = self.name
|
||||
component_cls = self.__class__
|
||||
|
||||
def renderer(root_attributes: Optional[List[str]] = None) -> Tuple[str, Dict[str, List[str]]]:
|
||||
trace_component_msg(
|
||||
"COMP_RENDER_START",
|
||||
component_name=component_name,
|
||||
component_id=render_id,
|
||||
slot_name=None,
|
||||
component_path=component_path,
|
||||
)
|
||||
|
||||
# Allow to access component input and metadata like component ID from within `on_render` hook
|
||||
with component._with_metadata(metadata):
|
||||
component.on_render_before(context, template)
|
||||
|
||||
# Emit signal that the template is about to be rendered
|
||||
template_rendered.send(sender=self, template=self, context=context)
|
||||
template_rendered.send(sender=template, template=template, context=context)
|
||||
# Get the component's HTML
|
||||
html_content = template.render(context)
|
||||
|
||||
# Allow to optionally override/modify the rendered content
|
||||
new_output = self.on_render_after(context, template, html_content)
|
||||
new_output = component.on_render_after(context, template, html_content)
|
||||
html_content = new_output if new_output is not None else html_content
|
||||
|
||||
# After rendering is done, remove the current state from the stack, which means
|
||||
# properties like `self.context` will no longer return the current state.
|
||||
self._render_stack.pop()
|
||||
context.render_context.pop()
|
||||
|
||||
# Internal component HTML post-processing:
|
||||
# - Add the HTML attributes to work with JS and CSS variables
|
||||
# - Resolve component's JS / CSS into <script> and <link> (if render_dependencies=True)
|
||||
#
|
||||
# However, to ensure that we run an HTML parser only once over the HTML content,
|
||||
# we have to wrap it in this callback. This callback runs only once we know whether
|
||||
# there are any extra HTML attributes that should be applied to this component's root elements.
|
||||
#
|
||||
# This makes it possible for multiple components to resolve to the same HTML element.
|
||||
# E.g. if CompA renders CompB, and CompB renders a <div>, then the <div> element will have
|
||||
# IDs of both CompA and CompB.
|
||||
# ```html
|
||||
# <div djc-id-a1b3cf djc-id-f3d3cf>...</div>
|
||||
# ```
|
||||
def post_processor(root_attributes: Optional[List[str]] = None) -> Tuple[str, Dict[str, List[str]]]:
|
||||
nonlocal html_content
|
||||
|
||||
# Add necessary HTML attributes to work with JS and CSS variables
|
||||
updated_html, child_components = set_component_attrs_for_js_and_css(
|
||||
html_content=html_content,
|
||||
component_id=render_id,
|
||||
css_input_hash=css_input_hash,
|
||||
css_scope_id=None, # TODO - Implement
|
||||
css_scope_id=css_scope_id,
|
||||
root_attributes=root_attributes,
|
||||
)
|
||||
|
||||
updated_html = postprocess_component_html(
|
||||
component_cls=self.__class__,
|
||||
# Prepend an HTML comment to instructs how and what JS and CSS scripts are associated with it.
|
||||
updated_html = insert_component_dependencies_comment(
|
||||
updated_html,
|
||||
component_cls=component_cls,
|
||||
component_id=render_id,
|
||||
html_content=updated_html,
|
||||
css_input_hash=css_input_hash,
|
||||
js_input_hash=js_input_hash,
|
||||
type=type,
|
||||
render_dependencies=render_dependencies,
|
||||
css_input_hash=css_input_hash,
|
||||
)
|
||||
|
||||
trace_component_msg(
|
||||
"COMP_RENDER_END",
|
||||
component_name=component_name,
|
||||
component_id=render_id,
|
||||
slot_name=None,
|
||||
component_path=component_path,
|
||||
)
|
||||
|
||||
return updated_html, child_components
|
||||
|
||||
return component_post_render(post_processor, render_id, parent_id)
|
||||
return renderer
|
||||
|
||||
def _normalize_slot_fills(
|
||||
self,
|
||||
|
@ -1129,12 +1261,41 @@ class Component(
|
|||
|
||||
# NOTE: `gen_escaped_content_func` is defined as a separate function, instead of being inlined within
|
||||
# the forloop, because the value the forloop variable points to changes with each loop iteration.
|
||||
def gen_escaped_content_func(content: SlotFunc) -> Slot:
|
||||
def content_fn(ctx: Context, slot_data: Dict, slot_ref: SlotRef) -> SlotResult:
|
||||
rendered = content(ctx, slot_data, slot_ref)
|
||||
return conditional_escape(rendered) if escape_content else rendered
|
||||
def gen_escaped_content_func(content: SlotFunc, slot_name: str) -> Slot:
|
||||
# Case: Already Slot, already escaped, and names tracing names assigned, so nothing to do.
|
||||
if isinstance(content, Slot) and content.escaped and content.slot_name and content.component_name:
|
||||
return content
|
||||
|
||||
# Otherwise, we create a new instance of Slot, whether `content` was already Slot or not.
|
||||
# This is so that user can potentially define a single `Slot` and use it in multiple components.
|
||||
if not isinstance(content, Slot) or not content.escaped:
|
||||
|
||||
def content_fn(ctx: Context, slot_data: Dict, slot_ref: SlotRef) -> SlotResult:
|
||||
rendered = content(ctx, slot_data, slot_ref)
|
||||
return conditional_escape(rendered) if escape_content else rendered
|
||||
|
||||
content_func = cast(SlotFunc, content_fn)
|
||||
else:
|
||||
content_func = content.content_func
|
||||
|
||||
# Populate potentially missing fields so we can trace the component and slot
|
||||
if isinstance(content, Slot):
|
||||
used_component_name = content.component_name or self.name
|
||||
used_slot_name = content.slot_name or slot_name
|
||||
used_nodelist = content.nodelist
|
||||
else:
|
||||
used_component_name = self.name
|
||||
used_slot_name = slot_name
|
||||
used_nodelist = None
|
||||
|
||||
slot = Slot(
|
||||
content_func=content_func,
|
||||
component_name=used_component_name,
|
||||
slot_name=used_slot_name,
|
||||
nodelist=used_nodelist,
|
||||
escaped=True,
|
||||
)
|
||||
|
||||
slot = Slot(content_func=cast(SlotFunc, content_fn))
|
||||
return slot
|
||||
|
||||
for slot_name, content in fills.items():
|
||||
|
@ -1142,13 +1303,14 @@ class Component(
|
|||
continue
|
||||
elif not callable(content):
|
||||
slot = _nodelist_to_slot_render_func(
|
||||
slot_name,
|
||||
NodeList([TextNode(conditional_escape(content) if escape_content else content)]),
|
||||
component_name=self.name,
|
||||
slot_name=slot_name,
|
||||
nodelist=NodeList([TextNode(conditional_escape(content) if escape_content else content)]),
|
||||
data_var=None,
|
||||
default_var=None,
|
||||
)
|
||||
else:
|
||||
slot = gen_escaped_content_func(content)
|
||||
slot = gen_escaped_content_func(content, slot_name)
|
||||
|
||||
norm_fills[slot_name] = slot
|
||||
|
||||
|
@ -1460,13 +1622,15 @@ def _prepare_template(
|
|||
component: Component,
|
||||
context: Context,
|
||||
context_data: Any,
|
||||
metadata: MetadataItem,
|
||||
) -> Generator[Template, Any, None]:
|
||||
with context.update(context_data):
|
||||
# Associate the newly-created Context with a Template, otherwise we get
|
||||
# an error when we try to use `{% include %}` tag inside the template?
|
||||
# See https://github.com/django-components/django-components/issues/580
|
||||
# And https://github.com/django-components/django-components/issues/634
|
||||
template = component._get_template(context)
|
||||
with component._with_metadata(metadata):
|
||||
template = component._get_template(context, component_id=metadata.render_id)
|
||||
|
||||
if not is_template_cls_patched(template):
|
||||
raise RuntimeError(
|
||||
|
|
|
@ -120,7 +120,7 @@ class DynamicComponent(Component):
|
|||
|
||||
# NOTE: The inner component is rendered in `on_render_before`, so that the `Context` object
|
||||
# is already configured as if the inner component was rendered inside the template.
|
||||
# E.g. the `_COMPONENT_SLOT_CTX_CONTEXT_KEY` is set, which means that the child component
|
||||
# E.g. the `_COMPONENT_CONTEXT_KEY` is set, which means that the child component
|
||||
# will know that it's a child of this component.
|
||||
def on_render_before(self, context: Context, template: Template) -> Context:
|
||||
comp_class = context["comp_class"]
|
||||
|
|
|
@ -2,34 +2,29 @@
|
|||
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.
|
||||
Compared to `django_components/util/context.py`, this file contains the "business" logic
|
||||
and the list of all internal keys that we define on the `Context` object.
|
||||
"""
|
||||
|
||||
from collections import namedtuple
|
||||
from typing import Any, Dict, Optional
|
||||
from django.template import Context
|
||||
|
||||
from django.template import Context, TemplateSyntaxError
|
||||
from django_components.util.misc import get_last_index
|
||||
|
||||
from django_components.util.misc import find_last_index
|
||||
|
||||
_COMPONENT_SLOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_COMPONENT_SLOT_CTX"
|
||||
_ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_ROOT_CTX"
|
||||
_REGISTRY_CONTEXT_KEY = "_DJANGO_COMPONENTS_REGISTRY"
|
||||
_INJECT_CONTEXT_KEY_PREFIX = "_DJANGO_COMPONENTS_INJECT__"
|
||||
_COMPONENT_CONTEXT_KEY = "_DJC_COMPONENT_CTX"
|
||||
_INJECT_CONTEXT_KEY_PREFIX = "_DJC_INJECT__"
|
||||
|
||||
|
||||
def make_isolated_context_copy(context: Context) -> Context:
|
||||
context_copy = context.new()
|
||||
copy_forloop_context(context, context_copy)
|
||||
_copy_forloop_context(context, context_copy)
|
||||
|
||||
# Required for compatibility with Django's {% extends %} tag
|
||||
# See https://github.com/django-components/django-components/pull/859
|
||||
context_copy.render_context = context.render_context
|
||||
|
||||
# Pass through our internal keys
|
||||
context_copy[_REGISTRY_CONTEXT_KEY] = context.get(_REGISTRY_CONTEXT_KEY, None)
|
||||
if _ROOT_CTX_CONTEXT_KEY in context:
|
||||
context_copy[_ROOT_CTX_CONTEXT_KEY] = context[_ROOT_CTX_CONTEXT_KEY]
|
||||
if _COMPONENT_CONTEXT_KEY in context:
|
||||
context_copy[_COMPONENT_CONTEXT_KEY] = context[_COMPONENT_CONTEXT_KEY]
|
||||
|
||||
# Make inject/provide to work in isolated mode
|
||||
context_keys = context.flatten().keys()
|
||||
|
@ -40,76 +35,14 @@ def make_isolated_context_copy(context: Context) -> Context:
|
|||
return context_copy
|
||||
|
||||
|
||||
def copy_forloop_context(from_context: Context, to_context: Context) -> None:
|
||||
def _copy_forloop_context(from_context: Context, to_context: Context) -> None:
|
||||
"""Forward the info about the current loop"""
|
||||
# Note that the ForNode (which implements for loop behavior) does not
|
||||
# Note that the ForNode (which implements `{% for %}`) does not
|
||||
# only add the `forloop` key, but also keys corresponding to the loop elements
|
||||
# So if the loop syntax is `{% for my_val in my_lists %}`, then ForNode also
|
||||
# sets a `my_val` key.
|
||||
# For this reason, instead of copying individual keys, we copy the whole stack layer
|
||||
# set by ForNode.
|
||||
if "forloop" in from_context:
|
||||
forloop_dict_index = find_last_index(from_context.dicts, lambda d: "forloop" in d)
|
||||
forloop_dict_index = get_last_index(from_context.dicts, lambda d: "forloop" in d) or -1
|
||||
to_context.update(from_context.dicts[forloop_dict_index])
|
||||
|
||||
|
||||
def get_injected_context_var(
|
||||
component_name: str,
|
||||
context: Context,
|
||||
key: str,
|
||||
default: Optional[Any] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieve a 'provided' field. The field MUST have been previously 'provided'
|
||||
by the component's ancestors using the `{% provide %}` template tag.
|
||||
"""
|
||||
# NOTE: For simplicity, we keep the provided values directly on the context.
|
||||
# This plays nicely with Django's Context, which behaves like a stack, so "newer"
|
||||
# values overshadow the "older" ones.
|
||||
internal_key = _INJECT_CONTEXT_KEY_PREFIX + key
|
||||
|
||||
# Return provided value if found
|
||||
if internal_key in context:
|
||||
return context[internal_key]
|
||||
|
||||
# If a default was given, return that
|
||||
if default is not None:
|
||||
return default
|
||||
|
||||
# Otherwise raise error
|
||||
raise KeyError(
|
||||
f"Component '{component_name}' tried to inject a variable '{key}' before it was provided."
|
||||
f" To fix this, make sure that at least one ancestor of component '{component_name}' has"
|
||||
f" the variable '{key}' in their 'provide' attribute."
|
||||
)
|
||||
|
||||
|
||||
def set_provided_context_var(
|
||||
context: Context,
|
||||
key: str,
|
||||
provided_kwargs: Dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
'Provide' given data under given key. In other words, this data can be retrieved
|
||||
using `self.inject(key)` inside of `get_context_data()` method of components that
|
||||
are nested inside the `{% provide %}` tag.
|
||||
"""
|
||||
# NOTE: We raise TemplateSyntaxError since this func should be called only from
|
||||
# within template.
|
||||
if not key:
|
||||
raise TemplateSyntaxError(
|
||||
"Provide tag received an empty string. Key must be non-empty and a valid identifier."
|
||||
)
|
||||
if not key.isidentifier():
|
||||
raise TemplateSyntaxError(
|
||||
"Provide tag received a non-identifier string. Key must be non-empty and a valid identifier."
|
||||
)
|
||||
|
||||
# We turn the kwargs into a NamedTuple so that the object that's "provided"
|
||||
# is immutable. This ensures that the data returned from `inject` will always
|
||||
# have all the keys that were passed to the `provide` tag.
|
||||
tpl_cls = namedtuple("DepInject", provided_kwargs.keys()) # type: ignore[misc]
|
||||
payload = tpl_cls(**provided_kwargs)
|
||||
|
||||
internal_key = _INJECT_CONTEXT_KEY_PREFIX + key
|
||||
context[internal_key] = payload
|
||||
|
|
|
@ -321,7 +321,24 @@ def set_component_attrs_for_js_and_css(
|
|||
return updated_html, child_components
|
||||
|
||||
|
||||
def _insert_component_comment(
|
||||
# NOTE: To better understand the next section, consider this:
|
||||
#
|
||||
# We define and cache the component's JS and CSS at the same time as
|
||||
# when we render the HTML. However, the resulting HTML MAY OR MAY NOT
|
||||
# be used in another component.
|
||||
#
|
||||
# IF the component's HTML IS used in another component, and the other
|
||||
# component want to render the JS or CSS dependencies (e.g. inside <head>),
|
||||
# then it's only at that point when we want to access the data about
|
||||
# which JS and CSS scripts is the component's HTML associated with.
|
||||
#
|
||||
# This happens AFTER the rendering context, so there's no Context to rely on.
|
||||
#
|
||||
# Hence, we store the info about associated JS and CSS right in the HTML itself.
|
||||
# As an HTML comment `<!-- -->`. Thus, the inner component can be used as many times
|
||||
# and in different components, and they will all know to fetch also JS and CSS of the
|
||||
# inner components.
|
||||
def insert_component_dependencies_comment(
|
||||
content: str,
|
||||
# NOTE: We pass around the component CLASS, so the dependencies logic is not
|
||||
# dependent on ComponentRegistries
|
||||
|
@ -329,7 +346,7 @@ def _insert_component_comment(
|
|||
component_id: str,
|
||||
js_input_hash: Optional[str],
|
||||
css_input_hash: Optional[str],
|
||||
) -> str:
|
||||
) -> SafeString:
|
||||
"""
|
||||
Given some textual content, prepend it with a short string that
|
||||
will be used by the ComponentDependencyMiddleware to collect all
|
||||
|
@ -343,54 +360,6 @@ def _insert_component_comment(
|
|||
return output
|
||||
|
||||
|
||||
# Anything and everything that needs to be done with a Component's HTML
|
||||
# script in order to support running JS and CSS per-instance.
|
||||
def postprocess_component_html(
|
||||
component_cls: Type["Component"],
|
||||
component_id: str,
|
||||
html_content: Union[str, SafeString],
|
||||
css_input_hash: Optional[str],
|
||||
js_input_hash: Optional[str],
|
||||
type: RenderType,
|
||||
render_dependencies: bool,
|
||||
) -> Union[str, SafeString]:
|
||||
is_safestring = isinstance(html_content, SafeString)
|
||||
|
||||
# NOTE: To better understand the next section, consider this:
|
||||
#
|
||||
# We define and cache the component's JS and CSS at the same time as
|
||||
# when we render the HTML. However, the resulting HTML MAY OR MAY NOT
|
||||
# be used in another component.
|
||||
#
|
||||
# IF the component's HTML IS used in another component, and the other
|
||||
# component want to render the JS or CSS dependencies (e.g. inside <head>),
|
||||
# then it's only at that point when we want to access the data about
|
||||
# which JS and CSS scripts is the component's HTML associated with.
|
||||
#
|
||||
# This happens AFTER the rendering context, so there's no Context to rely on.
|
||||
#
|
||||
# Hence, we store the info about associated JS and CSS right in the HTML itself.
|
||||
# As an HTML comment `<!-- -->`. Thus, the inner component can be used as many times
|
||||
# and in different components, and they will all know to fetch also JS and CSS of the
|
||||
# inner components.
|
||||
|
||||
# Mark the generated HTML so that we will know which JS and CSS
|
||||
# scripts are associated with it.
|
||||
output = _insert_component_comment(
|
||||
html_content,
|
||||
component_cls=component_cls,
|
||||
component_id=component_id,
|
||||
js_input_hash=js_input_hash,
|
||||
css_input_hash=css_input_hash,
|
||||
)
|
||||
|
||||
if render_dependencies:
|
||||
output = _render_dependencies(output, type)
|
||||
|
||||
output = mark_safe(output) if is_safestring else output
|
||||
return output
|
||||
|
||||
|
||||
#########################################################
|
||||
# 3. Given a FINAL HTML composed of MANY components,
|
||||
# process all the HTML dependency comments (created in
|
||||
|
|
|
@ -6,7 +6,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, Type, cast
|
|||
from django.template import Context, Library
|
||||
from django.template.base import Node, NodeList, Parser, Token
|
||||
|
||||
from django_components.util.logger import trace_msg
|
||||
from django_components.util.logger import trace_node_msg
|
||||
from django_components.util.misc import gen_id
|
||||
from django_components.util.template_tag import (
|
||||
TagAttr,
|
||||
|
@ -89,7 +89,7 @@ class NodeMeta(type):
|
|||
|
||||
@functools.wraps(orig_render)
|
||||
def wrapper_render(self: "BaseNode", context: Context) -> str:
|
||||
trace_msg("RENDR", self.tag, self.node_id)
|
||||
trace_node_msg("RENDER", self.tag, self.node_id)
|
||||
|
||||
resolved_params = resolve_params(self.tag, self.params, context)
|
||||
|
||||
|
@ -196,7 +196,7 @@ class NodeMeta(type):
|
|||
] + resolved_params_without_invalid_kwargs
|
||||
output = apply_params_in_original_order(orig_render, resolved_params_with_context, invalid_kwargs)
|
||||
|
||||
trace_msg("RENDR", self.tag, self.node_id, msg="...Done!")
|
||||
trace_node_msg("RENDER", self.tag, self.node_id, msg="...Done!")
|
||||
return output
|
||||
|
||||
# Wrap cls.render() so we resolve the args and kwargs and pass them to the
|
||||
|
@ -357,7 +357,7 @@ class BaseNode(Node, metaclass=NodeMeta):
|
|||
tag_id = gen_id()
|
||||
tag = parse_template_tag(cls.tag, cls.end_tag, cls.allowed_flags, parser, token)
|
||||
|
||||
trace_msg("PARSE", cls.tag, tag_id)
|
||||
trace_node_msg("PARSE", cls.tag, tag_id)
|
||||
|
||||
body = tag.parse_body()
|
||||
node = cls(
|
||||
|
@ -368,7 +368,7 @@ class BaseNode(Node, metaclass=NodeMeta):
|
|||
**kwargs,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", cls.tag, tag_id, "...Done!")
|
||||
trace_node_msg("PARSE", cls.tag, tag_id, "...Done!")
|
||||
return node
|
||||
|
||||
@classmethod
|
||||
|
|
|
@ -1,9 +1,40 @@
|
|||
import re
|
||||
from collections import deque
|
||||
from typing import Callable, Deque, Dict, List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Callable, Deque, Dict, List, NamedTuple, Optional, Tuple
|
||||
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from django_components.util.exception import component_error_message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django_components.component import ComponentContext
|
||||
|
||||
# When we're inside a component's template, we need to acccess some component data,
|
||||
# as defined by `ComponentContext`. If we have nested components, then
|
||||
# each nested component will point to the Context of its parent component
|
||||
# via `outer_context`. This make is possible to access the correct data
|
||||
# inside `{% fill %}` tags.
|
||||
#
|
||||
# Previously, `ComponentContext` was stored directly on the `Context` object, but
|
||||
# this was problematic:
|
||||
# - The need for creating a Context snapshot meant potentially a lot of copying
|
||||
# - It was hard to trace and debug. Because if you printed the Context, it included the
|
||||
# `ComponentContext` data, including the `outer_context` which contained another
|
||||
# `ComponentContext` object, and so on.
|
||||
#
|
||||
# Thus, similarly to the data stored by `{% provide %}`, we store the actual
|
||||
# `ComponentContext` data on a separate dictionary, and what's passed through the Context
|
||||
# is only a key to this dictionary.
|
||||
component_context_cache: Dict[str, "ComponentContext"] = {}
|
||||
|
||||
|
||||
class PostRenderQueueItem(NamedTuple):
|
||||
content_before_component: str
|
||||
component_id: Optional[str]
|
||||
parent_id: Optional[str]
|
||||
component_name_path: List[str]
|
||||
|
||||
|
||||
# Function that accepts a list of extra HTML attributes to be set on the component's root elements
|
||||
# and returns the component's HTML content and a dictionary of child components' IDs
|
||||
# and their root elements' HTML attributes.
|
||||
|
@ -13,8 +44,8 @@ from django.utils.safestring import mark_safe
|
|||
ComponentRenderer = Callable[[Optional[List[str]]], Tuple[str, Dict[str, List[str]]]]
|
||||
|
||||
# Render-time cache for component rendering
|
||||
# See Component._post_render()
|
||||
component_renderer_cache: Dict[str, ComponentRenderer] = {}
|
||||
# See component_post_render()
|
||||
component_renderer_cache: Dict[str, Tuple[ComponentRenderer, str]] = {}
|
||||
child_component_attrs: Dict[str, List[str]] = {}
|
||||
|
||||
nested_comp_pattern = re.compile(r'<template [^>]*?djc-render-id="\w{6}"[^>]*?></template>')
|
||||
|
@ -69,12 +100,15 @@ render_id_pattern = re.compile(r'djc-render-id="(?P<render_id>\w{6})"')
|
|||
def component_post_render(
|
||||
renderer: ComponentRenderer,
|
||||
render_id: str,
|
||||
component_name: str,
|
||||
parent_id: Optional[str],
|
||||
on_component_rendered: Callable[[str], None],
|
||||
on_html_rendered: Callable[[str], str],
|
||||
) -> str:
|
||||
# Instead of rendering the component's HTML content immediately, we store it,
|
||||
# so we can render the component only once we know if there are any HTML attributes
|
||||
# to be applied to the resulting HTML.
|
||||
component_renderer_cache[render_id] = renderer
|
||||
component_renderer_cache[render_id] = (renderer, component_name)
|
||||
|
||||
if parent_id is not None:
|
||||
# Case: Nested component
|
||||
|
@ -113,27 +147,44 @@ def component_post_render(
|
|||
# 4. We split the content by placeholders, and put the pairs of (content, placeholder_id) into the queue,
|
||||
# repeating this whole process until we've processed all nested components.
|
||||
content_parts: List[str] = []
|
||||
process_queue: Deque[Tuple[str, Optional[str]]] = deque()
|
||||
process_queue: Deque[PostRenderQueueItem] = deque()
|
||||
|
||||
process_queue.append(("", render_id))
|
||||
process_queue.append(
|
||||
PostRenderQueueItem(
|
||||
content_before_component="",
|
||||
component_id=render_id,
|
||||
parent_id=None,
|
||||
component_name_path=[],
|
||||
)
|
||||
)
|
||||
|
||||
while len(process_queue):
|
||||
curr_content_before_component, curr_comp_id = process_queue.popleft()
|
||||
curr_item = process_queue.popleft()
|
||||
|
||||
# Process content before the component
|
||||
if curr_content_before_component:
|
||||
content_parts.append(curr_content_before_component)
|
||||
if curr_item.content_before_component:
|
||||
content_parts.append(curr_item.content_before_component)
|
||||
|
||||
# The entry was only a remaining text, no more components to process, we're done
|
||||
if curr_comp_id is None:
|
||||
# In this case we've reached the end of the component's HTML content, and there's
|
||||
# no more subcomponents to process.
|
||||
if curr_item.component_id is None:
|
||||
on_component_rendered(curr_item.parent_id) # type: ignore[arg-type]
|
||||
continue
|
||||
|
||||
# Generate component's content, applying the extra HTML attributes set by the parent component
|
||||
curr_comp_renderer = component_renderer_cache.pop(curr_comp_id)
|
||||
curr_comp_renderer, curr_comp_name = component_renderer_cache.pop(curr_item.component_id)
|
||||
# NOTE: This may be undefined, because this is set only for components that
|
||||
# are also root elements in their parent's HTML
|
||||
curr_comp_attrs = child_component_attrs.pop(curr_comp_id, None)
|
||||
curr_comp_content, curr_child_component_attrs = curr_comp_renderer(curr_comp_attrs)
|
||||
curr_comp_attrs = child_component_attrs.pop(curr_item.component_id, None)
|
||||
|
||||
full_path = [*curr_item.component_name_path, curr_comp_name]
|
||||
|
||||
# This is where we actually render the component
|
||||
#
|
||||
# NOTE: [1:] because the root component will be yet again added to the error's
|
||||
# `components` list in `_render` so we remove the first element from the path.
|
||||
with component_error_message(full_path[1:]):
|
||||
curr_comp_content, curr_child_component_attrs = curr_comp_renderer(curr_comp_attrs)
|
||||
|
||||
# Exclude the `data-djc-scope-...` attribute from being applied to the child component's HTML
|
||||
for key in list(curr_child_component_attrs.keys()):
|
||||
|
@ -144,7 +195,7 @@ def component_post_render(
|
|||
|
||||
# Process the component's content
|
||||
last_index = 0
|
||||
parts_to_process: List[Tuple[str, Optional[str]]] = []
|
||||
parts_to_process: List[PostRenderQueueItem] = []
|
||||
|
||||
# Split component's content by placeholders, and put the pairs of (content, placeholder_id) into the queue
|
||||
for match in nested_comp_pattern.finditer(curr_comp_content):
|
||||
|
@ -157,13 +208,33 @@ def component_post_render(
|
|||
if curr_child_id_match is None:
|
||||
raise ValueError(f"No placeholder ID found in {comp_part}")
|
||||
curr_child_id = curr_child_id_match.group("render_id")
|
||||
parts_to_process.append((part_before_component, curr_child_id))
|
||||
parts_to_process.append(
|
||||
PostRenderQueueItem(
|
||||
content_before_component=part_before_component,
|
||||
component_id=curr_child_id,
|
||||
parent_id=curr_item.component_id,
|
||||
component_name_path=full_path,
|
||||
)
|
||||
)
|
||||
|
||||
# Append any remaining text
|
||||
if last_index < len(curr_comp_content):
|
||||
parts_to_process.append((curr_comp_content[last_index:], None))
|
||||
parts_to_process.append(
|
||||
PostRenderQueueItem(
|
||||
content_before_component=curr_comp_content[last_index:],
|
||||
# Setting component_id to None means that this is the last part of the component's HTML
|
||||
# and we're done with this component
|
||||
component_id=None,
|
||||
parent_id=curr_item.component_id,
|
||||
component_name_path=full_path,
|
||||
)
|
||||
)
|
||||
|
||||
process_queue.extendleft(reversed(parts_to_process))
|
||||
|
||||
# Lastly, join up all pieces of the component's HTML content
|
||||
output = "".join(content_parts)
|
||||
|
||||
output = on_html_rendered(output)
|
||||
|
||||
return mark_safe(output)
|
||||
|
|
155
src/django_components/perfutil/provide.py
Normal file
155
src/django_components/perfutil/provide.py
Normal file
|
@ -0,0 +1,155 @@
|
|||
"""
|
||||
This module contains optimizations for the `{% provide %}` feature.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict, Generator, NamedTuple, Set
|
||||
|
||||
from django.template import Context
|
||||
|
||||
from django_components.context import _INJECT_CONTEXT_KEY_PREFIX
|
||||
|
||||
# Originally, when `{% provide %}` was used, the provided data was passed down
|
||||
# through the Context object.
|
||||
#
|
||||
# However, this was hard to debug if the provided data was large (e.g. a long
|
||||
# list of items).
|
||||
#
|
||||
# Instead, similarly to how the component internal data is passed through
|
||||
# the Context object, there's now a level of indirection - the Context now stores
|
||||
# only a key that points to the provided data.
|
||||
#
|
||||
# So when we inspect a Context layers, we may see something like this:
|
||||
#
|
||||
# ```py
|
||||
# [
|
||||
# {"False": False, "None": None, "True": True}, # All Contexts contain this
|
||||
# {"custom_key": "custom_value"}, # Data passed to Context()
|
||||
# {"_DJC_INJECT__my_provide": "a1b3c3"}, # Data provided by {% provide %}
|
||||
# # containing only the key to "my_provide"
|
||||
# ]
|
||||
# ```
|
||||
#
|
||||
# Since the provided data is represented only as a key, we have to store the ACTUAL
|
||||
# data somewhere. Thus, we store it in a separate dictionary.
|
||||
#
|
||||
# So when one calls `Component.inject(key)`, we use the key to look up the actual data
|
||||
# in the dictionary and return that.
|
||||
#
|
||||
# This approach has several benefits:
|
||||
# - Debugging: One needs to only follow the IDs to trace the flow of data.
|
||||
# - Debugging: All provided data is stored in a separate dictionary, so it's easy to
|
||||
# see what data is provided.
|
||||
# - Perf: The Context object is copied each time we call `Component.render()`, to have a "snapshot"
|
||||
# of the context, in order to defer the rendering. Passing around only the key instead
|
||||
# of actual value avoids potentially copying the provided data. This also keeps the source of truth
|
||||
# unambiguous.
|
||||
# - Tests: It's easier to test the provided data, as we can just modify the dictionary directly.
|
||||
#
|
||||
# However, there is a price to pay for this:
|
||||
# - Manual memory management - Because the data is stored in a separate dictionary, we now need to
|
||||
# keep track of when to delete the entries.
|
||||
#
|
||||
# The challenge with this manual memory management is that:
|
||||
# 1. Component rendering is deferred, so components are rendered AFTER we finish `Template.render()`.
|
||||
# 2. For the deferred rendering, we copy the Context object.
|
||||
#
|
||||
# This means that:
|
||||
# 1. We can't rely on simply reaching the end of `Template.render()` to delete the provided data.
|
||||
# 2. We can't rely on the Context object being deleted to delete the provided data.
|
||||
#
|
||||
# So we need to manually delete the provided data when we know it's no longer needed.
|
||||
#
|
||||
# Thus, the strategy is to count references to the provided data:
|
||||
# 1. When `{% provide %}` is used, it adds a key to the context.
|
||||
# 2. When we come across `{% component %}` that is within the `{% provide %}` tags,
|
||||
# the component will see the provide's key and the component will register itself as a "child" of
|
||||
# the `{% provide %}` tag at `Component.render()`.
|
||||
# 3. Once the component's deferred rendering takes place and finishes, the component makes a call
|
||||
# to unregister itself from any "subscribed" provided data.
|
||||
# 4. While unsubscribing, if we see that there are no more children subscribed to the provided data,
|
||||
# we can finally delete the provided data from the cache.
|
||||
#
|
||||
# However, this leaves open the edge case of when `{% provide %}` contains NO components.
|
||||
# In such case, we check if there are any subscribed components after rendering the contents
|
||||
# of `{% provide %}`. If there are NONE, we delete the provided data.
|
||||
|
||||
|
||||
# Similarly to ComponentContext instances, we store the actual Provided data
|
||||
# outside of the Context object, to make it easier to debug the data flow.
|
||||
provide_cache: Dict[str, NamedTuple] = {}
|
||||
|
||||
# Keep track of how many components are referencing each provided data.
|
||||
provide_references: Dict[str, Set[str]] = {}
|
||||
|
||||
# Keep track of all the listeners that are referencing any provided data.
|
||||
all_reference_ids: Set[str] = set()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def managed_provide_cache(provide_id: str) -> Generator[None, None, None]:
|
||||
all_reference_ids_before = all_reference_ids.copy()
|
||||
|
||||
def cache_cleanup() -> None:
|
||||
# Lastly, remove provided data from the cache that was generated during this run,
|
||||
# IF there are no more references to it.
|
||||
if provide_id in provide_references and not provide_references[provide_id]:
|
||||
provide_references.pop(provide_id)
|
||||
provide_cache.pop(provide_id)
|
||||
|
||||
# Case: `{% provide %}` contained no components in its body.
|
||||
# The provided data was not referenced by any components, but it's still in the cache.
|
||||
elif provide_id not in provide_references and provide_id in provide_cache:
|
||||
provide_cache.pop(provide_id)
|
||||
|
||||
try:
|
||||
yield
|
||||
except Exception as e:
|
||||
# In case of an error in `Component.render()`, there may be some
|
||||
# references left hanging, so we remove them.
|
||||
new_reference_ids = all_reference_ids - all_reference_ids_before
|
||||
for reference_id in new_reference_ids:
|
||||
unregister_provide_reference(reference_id)
|
||||
|
||||
# Cleanup
|
||||
cache_cleanup()
|
||||
# Forward the error
|
||||
raise e from None
|
||||
|
||||
# Cleanup
|
||||
cache_cleanup()
|
||||
|
||||
|
||||
def register_provide_reference(context: Context, reference_id: str) -> None:
|
||||
# No `{% provide %}` among the ancestors, nothing to register to
|
||||
if not provide_cache:
|
||||
return
|
||||
|
||||
all_reference_ids.add(reference_id)
|
||||
|
||||
for key, provide_id in context.flatten().items():
|
||||
if not key.startswith(_INJECT_CONTEXT_KEY_PREFIX):
|
||||
continue
|
||||
|
||||
if provide_id not in provide_references:
|
||||
provide_references[provide_id] = set()
|
||||
provide_references[provide_id].add(reference_id)
|
||||
|
||||
|
||||
def unregister_provide_reference(reference_id: str) -> None:
|
||||
# No registered references, nothing to unregister
|
||||
if reference_id not in all_reference_ids:
|
||||
return
|
||||
|
||||
all_reference_ids.remove(reference_id)
|
||||
|
||||
for provide_id in list(provide_references.keys()):
|
||||
if reference_id not in provide_references[provide_id]:
|
||||
continue
|
||||
|
||||
provide_references[provide_id].remove(reference_id)
|
||||
|
||||
# There are no more references to the provided data, so we can delete it.
|
||||
if not provide_references[provide_id]:
|
||||
provide_cache.pop(provide_id)
|
||||
provide_references.pop(provide_id)
|
|
@ -1,10 +1,13 @@
|
|||
from typing import Any
|
||||
from collections import namedtuple
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from django.template import Context
|
||||
from django.template import Context, TemplateSyntaxError
|
||||
from django.utils.safestring import SafeString
|
||||
|
||||
from django_components.context import set_provided_context_var
|
||||
from django_components.context import _INJECT_CONTEXT_KEY_PREFIX
|
||||
from django_components.node import BaseNode
|
||||
from django_components.perfutil.provide import managed_provide_cache, provide_cache
|
||||
from django_components.util.misc import gen_id
|
||||
|
||||
|
||||
class ProvideNode(BaseNode):
|
||||
|
@ -87,8 +90,79 @@ class ProvideNode(BaseNode):
|
|||
# add the provided kwargs into the Context.
|
||||
with context.update({}):
|
||||
# "Provide" the data to child nodes
|
||||
set_provided_context_var(context, name, kwargs)
|
||||
provide_id = set_provided_context_var(context, name, kwargs)
|
||||
|
||||
output = self.nodelist.render(context)
|
||||
# `managed_provide_cache` will remove the cache entry at the end if no components reference it.
|
||||
with managed_provide_cache(provide_id):
|
||||
output = self.nodelist.render(context)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def get_injected_context_var(
|
||||
component_name: str,
|
||||
context: Context,
|
||||
key: str,
|
||||
default: Optional[Any] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieve a 'provided' field. The field MUST have been previously 'provided'
|
||||
by the component's ancestors using the `{% provide %}` template tag.
|
||||
"""
|
||||
# NOTE: For simplicity, we keep the provided values directly on the context.
|
||||
# This plays nicely with Django's Context, which behaves like a stack, so "newer"
|
||||
# values overshadow the "older" ones.
|
||||
internal_key = _INJECT_CONTEXT_KEY_PREFIX + key
|
||||
|
||||
# Return provided value if found
|
||||
if internal_key in context:
|
||||
cache_key = context[internal_key]
|
||||
return provide_cache[cache_key]
|
||||
|
||||
# If a default was given, return that
|
||||
if default is not None:
|
||||
return default
|
||||
|
||||
# Otherwise raise error
|
||||
raise KeyError(
|
||||
f"Component '{component_name}' tried to inject a variable '{key}' before it was provided."
|
||||
f" To fix this, make sure that at least one ancestor of component '{component_name}' has"
|
||||
f" the variable '{key}' in their 'provide' attribute."
|
||||
)
|
||||
|
||||
|
||||
def set_provided_context_var(
|
||||
context: Context,
|
||||
key: str,
|
||||
provided_kwargs: Dict[str, Any],
|
||||
) -> str:
|
||||
"""
|
||||
'Provide' given data under given key. In other words, this data can be retrieved
|
||||
using `self.inject(key)` inside of `get_context_data()` method of components that
|
||||
are nested inside the `{% provide %}` tag.
|
||||
"""
|
||||
# NOTE: We raise TemplateSyntaxError since this func should be called only from
|
||||
# within template.
|
||||
if not key:
|
||||
raise TemplateSyntaxError(
|
||||
"Provide tag received an empty string. Key must be non-empty and a valid identifier."
|
||||
)
|
||||
if not key.isidentifier():
|
||||
raise TemplateSyntaxError(
|
||||
"Provide tag received a non-identifier string. Key must be non-empty and a valid identifier."
|
||||
)
|
||||
|
||||
# We turn the kwargs into a NamedTuple so that the object that's "provided"
|
||||
# is immutable. This ensures that the data returned from `inject` will always
|
||||
# have all the keys that were passed to the `provide` tag.
|
||||
tpl_cls = namedtuple("DepInject", provided_kwargs.keys()) # type: ignore[misc]
|
||||
payload = tpl_cls(**provided_kwargs)
|
||||
|
||||
# Instead of storing the provided data on the Context object, we store it
|
||||
# in a separate dictionary, and we set only the key to the data on the Context.
|
||||
context_key = _INJECT_CONTEXT_KEY_PREFIX + key
|
||||
provide_id = gen_id()
|
||||
context[context_key] = provide_id
|
||||
provide_cache[provide_id] = payload
|
||||
|
||||
return provide_id
|
||||
|
|
|
@ -19,23 +19,21 @@ from typing import (
|
|||
runtime_checkable,
|
||||
)
|
||||
|
||||
from django.template import Context
|
||||
from django.template import Context, Template
|
||||
from django.template.base import NodeList, TextNode
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
from django_components.app_settings import ContextBehavior
|
||||
from django_components.context import (
|
||||
_COMPONENT_SLOT_CTX_CONTEXT_KEY,
|
||||
_INJECT_CONTEXT_KEY_PREFIX,
|
||||
_REGISTRY_CONTEXT_KEY,
|
||||
_ROOT_CTX_CONTEXT_KEY,
|
||||
)
|
||||
from django_components.context import _COMPONENT_CONTEXT_KEY, _INJECT_CONTEXT_KEY_PREFIX
|
||||
from django_components.node import BaseNode
|
||||
from django_components.util.misc import get_last_index, is_identifier
|
||||
from django_components.perfutil.component import component_context_cache
|
||||
from django_components.util.exception import add_slot_to_error_message
|
||||
from django_components.util.logger import trace_component_msg
|
||||
from django_components.util.misc import get_index, get_last_index, is_identifier
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django_components.component_registry import ComponentRegistry
|
||||
from django_components.component import ComponentContext
|
||||
|
||||
TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True)
|
||||
|
||||
|
@ -65,6 +63,16 @@ class Slot(Generic[TSlotData]):
|
|||
"""This class holds the slot content function along with related metadata."""
|
||||
|
||||
content_func: SlotFunc[TSlotData]
|
||||
escaped: bool = False
|
||||
"""Whether the slot content has been escaped."""
|
||||
|
||||
# Following fields are only for debugging
|
||||
component_name: Optional[str] = None
|
||||
"""Name of the component that originally defined or accepted this slot fill."""
|
||||
slot_name: Optional[str] = None
|
||||
"""Name of the slot that originally defined or accepted this slot fill."""
|
||||
nodelist: Optional[NodeList] = None
|
||||
"""Nodelist of the slot content."""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not callable(self.content_func):
|
||||
|
@ -85,6 +93,11 @@ class Slot(Generic[TSlotData]):
|
|||
def do_not_call_in_templates(self) -> bool:
|
||||
return True
|
||||
|
||||
def __repr__(self) -> str:
|
||||
comp_name = f"'{self.component_name}'" if self.component_name else None
|
||||
slot_name = f"'{self.slot_name}'" if self.slot_name else None
|
||||
return f"<{self.__class__.__name__} component_name={comp_name} slot_name={slot_name}>"
|
||||
|
||||
|
||||
# Internal type aliases
|
||||
SlotName = str
|
||||
|
@ -137,16 +150,6 @@ class SlotIsFilled(dict):
|
|||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComponentSlotContext:
|
||||
component_name: str
|
||||
component_id: str
|
||||
template_name: str
|
||||
is_dynamic_component: bool
|
||||
default_slot: Optional[str]
|
||||
fills: Dict[SlotName, Slot]
|
||||
|
||||
|
||||
class SlotNode(BaseNode):
|
||||
"""
|
||||
Slot tag marks a place inside a component where content can be inserted
|
||||
|
@ -303,18 +306,31 @@ class SlotNode(BaseNode):
|
|||
if _is_extracting_fill(context):
|
||||
return ""
|
||||
|
||||
if _COMPONENT_SLOT_CTX_CONTEXT_KEY not in context or not context[_COMPONENT_SLOT_CTX_CONTEXT_KEY]:
|
||||
if _COMPONENT_CONTEXT_KEY not in context or not context[_COMPONENT_CONTEXT_KEY]:
|
||||
raise TemplateSyntaxError(
|
||||
"Encountered a SlotNode outside of a ComponentNode context. "
|
||||
"Encountered a SlotNode outside of a Component context. "
|
||||
"Make sure that all {% slot %} tags are nested within {% component %} tags.\n"
|
||||
f"SlotNode: {self.__repr__()}"
|
||||
)
|
||||
|
||||
component_ctx: ComponentSlotContext = context[_COMPONENT_SLOT_CTX_CONTEXT_KEY]
|
||||
component_id: str = context[_COMPONENT_CONTEXT_KEY]
|
||||
component_ctx = component_context_cache[component_id]
|
||||
component_name = component_ctx.component_name
|
||||
component_path = component_ctx.component_path
|
||||
slot_fills = component_ctx.fills
|
||||
slot_name = name
|
||||
is_default = self.flags[SLOT_DEFAULT_KEYWORD]
|
||||
is_required = self.flags[SLOT_REQUIRED_KEYWORD]
|
||||
|
||||
trace_component_msg(
|
||||
"RENDER_SLOT_START",
|
||||
component_name=component_name,
|
||||
component_id=component_id,
|
||||
slot_name=slot_name,
|
||||
component_path=component_path,
|
||||
slot_fills=slot_fills,
|
||||
)
|
||||
|
||||
# Check for errors
|
||||
if is_default and not component_ctx.is_dynamic_component:
|
||||
# Allow one slot to be marked as 'default', or multiple slots but with
|
||||
|
@ -325,7 +341,7 @@ class SlotNode(BaseNode):
|
|||
"Only one component slot may be marked as 'default', "
|
||||
f"found '{default_slot_name}' and '{slot_name}'. "
|
||||
f"To fix, check template '{component_ctx.template_name}' "
|
||||
f"of component '{component_ctx.component_name}'."
|
||||
f"of component '{component_name}'."
|
||||
)
|
||||
|
||||
if default_slot_name is None:
|
||||
|
@ -335,23 +351,88 @@ class SlotNode(BaseNode):
|
|||
# by specifying the fill both by explicit slot name and implicitly as 'default'.
|
||||
if (
|
||||
slot_name != DEFAULT_SLOT_KEY
|
||||
and component_ctx.fills.get(slot_name, False)
|
||||
and component_ctx.fills.get(DEFAULT_SLOT_KEY, False)
|
||||
and slot_fills.get(slot_name, False)
|
||||
and slot_fills.get(DEFAULT_SLOT_KEY, False)
|
||||
):
|
||||
raise TemplateSyntaxError(
|
||||
f"Slot '{slot_name}' of component '{component_ctx.component_name}' was filled twice: "
|
||||
f"Slot '{slot_name}' of component '{component_name}' was filled twice: "
|
||||
"once explicitly and once implicitly as 'default'."
|
||||
)
|
||||
|
||||
# If slot is marked as 'default', we use the name 'default' for the fill,
|
||||
# IF SUCH FILL EXISTS. Otherwise, we use the slot's name.
|
||||
if is_default and DEFAULT_SLOT_KEY in component_ctx.fills:
|
||||
if is_default and DEFAULT_SLOT_KEY in slot_fills:
|
||||
fill_name = DEFAULT_SLOT_KEY
|
||||
else:
|
||||
fill_name = slot_name
|
||||
|
||||
if fill_name in component_ctx.fills:
|
||||
slot_fill_fn = component_ctx.fills[fill_name]
|
||||
# NOTE: TBH not sure why this happens. But there's an edge case when:
|
||||
# 1. Using the "django" context behavior
|
||||
# 2. AND the slot fill is defined in the root template
|
||||
#
|
||||
# Then `ctx_with_fills.fills` does NOT contain any fills (`{% fill %}`). So in this case,
|
||||
# we need to use a different strategy to find the fills Context layer that contains the fills.
|
||||
#
|
||||
# ------------------------------------------------------------------------------------------
|
||||
#
|
||||
# Context:
|
||||
# When we render slot fills, we want to use the context as was OUTSIDE of the component.
|
||||
# E.g. In this example, we want to render `{{ item.name }}` inside the `{% fill %}` tag:
|
||||
#
|
||||
# ```django
|
||||
# {% for item in items %}
|
||||
# {% component "my_component" %}
|
||||
# {% fill "my_slot" %}
|
||||
# {{ item.name }}
|
||||
# {% endfill %}
|
||||
# {% endcomponent %}
|
||||
# {% endfor %}
|
||||
# ```
|
||||
#
|
||||
# In this case, we need to find the context that was used to render the component,
|
||||
# and use the fills from that context.
|
||||
if (
|
||||
component_ctx.registry.settings.context_behavior == ContextBehavior.DJANGO
|
||||
and component_ctx.outer_context is None
|
||||
):
|
||||
# When we have nested components with fills, the context layers are added in
|
||||
# the following order:
|
||||
# Page -> SubComponent -> NestedComponent -> ChildComponent
|
||||
#
|
||||
# Then, if ChildComponent defines a `{% slot %}` tag, its `{% fill %}` will be defined
|
||||
# within the context of its parent, NestedComponent. The context is updated as follows:
|
||||
# Page -> SubComponent -> NestedComponent -> ChildComponent -> NestedComponent
|
||||
#
|
||||
# And if, WITHIN THAT `{% fill %}`, there is another `{% slot %}` tag, its `{% fill %}`
|
||||
# will be defined within the context of its parent, SubComponent. The context becomes:
|
||||
# Page -> SubComponent -> NestedComponent -> ChildComponent -> NestedComponent -> SubComponent
|
||||
#
|
||||
# If that top-level `{% fill %}` defines a `{% component %}`, and the component accepts a `{% fill %}`,
|
||||
# we'd go one down inside the component, and then one up outside of it inside the `{% fill %}`.
|
||||
# Page -> SubComponent -> NestedComponent -> ChildComponent -> NestedComponent -> SubComponent ->
|
||||
# -> CompA -> SubComponent
|
||||
#
|
||||
# So, given a context of nested components like this, we need to find which component was parent
|
||||
# of the current component, and use the fills from that component.
|
||||
#
|
||||
# In the Context, the components are identified by their ID, NOT by their name, as in the example above.
|
||||
# So the path is more like this:
|
||||
# ax3c89 -> hui3q2 -> kok92a -> a1b2c3 -> kok92a -> hui3q2 -> d4e5f6 -> hui3q2
|
||||
#
|
||||
# We're at the right-most `hui3q2`, and we want to find `ax3c89`.
|
||||
# To achieve that, we first find the left-most `hui3q2`, and then find the `ax3c89`
|
||||
# in the list of dicts before it.
|
||||
curr_index = get_index(
|
||||
context.dicts, lambda d: _COMPONENT_CONTEXT_KEY in d and d[_COMPONENT_CONTEXT_KEY] == component_id
|
||||
)
|
||||
parent_index = get_last_index(context.dicts[:curr_index], lambda d: _COMPONENT_CONTEXT_KEY in d)
|
||||
if parent_index is not None:
|
||||
ctx_id_with_fills = context.dicts[parent_index][_COMPONENT_CONTEXT_KEY]
|
||||
ctx_with_fills = component_context_cache[ctx_id_with_fills]
|
||||
slot_fills = ctx_with_fills.fills
|
||||
|
||||
if fill_name in slot_fills:
|
||||
slot_fill_fn = slot_fills[fill_name]
|
||||
slot_fill = SlotFill(
|
||||
name=slot_name,
|
||||
is_filled=True,
|
||||
|
@ -363,6 +444,7 @@ class SlotNode(BaseNode):
|
|||
name=slot_name,
|
||||
is_filled=False,
|
||||
slot=_nodelist_to_slot_render_func(
|
||||
component_name=component_name,
|
||||
slot_name=slot_name,
|
||||
nodelist=self.nodelist,
|
||||
data_var=None,
|
||||
|
@ -385,7 +467,7 @@ class SlotNode(BaseNode):
|
|||
f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), "
|
||||
f"yet no fill is provided. Check template.'"
|
||||
)
|
||||
fill_names = list(component_ctx.fills.keys())
|
||||
fill_names = list(slot_fills.keys())
|
||||
if fill_names:
|
||||
fuzzy_fill_name_matches = difflib.get_close_matches(fill_name, fill_names, n=1, cutoff=0.7)
|
||||
if fuzzy_fill_name_matches:
|
||||
|
@ -404,8 +486,8 @@ class SlotNode(BaseNode):
|
|||
# then we will enter an endless loop. E.g.:
|
||||
# ```django
|
||||
# {% component "mycomponent" %}
|
||||
# {% slot "content" %}
|
||||
# {% fill "content" %}
|
||||
# {% slot "content" %} <--,
|
||||
# {% fill "content" %} ---'
|
||||
# ...
|
||||
# {% endfill %}
|
||||
# {% endslot %}
|
||||
|
@ -413,14 +495,18 @@ class SlotNode(BaseNode):
|
|||
# ```
|
||||
#
|
||||
# Hence, even in the "django" mode, we MUST use slots of the context of the parent component.
|
||||
all_ctxs = [d for d in context.dicts if _COMPONENT_SLOT_CTX_CONTEXT_KEY in d]
|
||||
if len(all_ctxs) > 1:
|
||||
second_to_last_ctx = all_ctxs[-2]
|
||||
extra_context[_COMPONENT_SLOT_CTX_CONTEXT_KEY] = second_to_last_ctx[_COMPONENT_SLOT_CTX_CONTEXT_KEY]
|
||||
if (
|
||||
component_ctx.registry.settings.context_behavior == ContextBehavior.DJANGO
|
||||
and component_ctx.outer_context is not None
|
||||
and _COMPONENT_CONTEXT_KEY in component_ctx.outer_context
|
||||
):
|
||||
extra_context[_COMPONENT_CONTEXT_KEY] = component_ctx.outer_context[_COMPONENT_CONTEXT_KEY]
|
||||
# This ensures that `component_vars.is_filled`is accessible in the fill
|
||||
extra_context["component_vars"] = component_ctx.outer_context["component_vars"]
|
||||
|
||||
# Irrespective of which context we use ("root" context or the one passed to this
|
||||
# render function), pass down the keys used by inject/provide feature. This makes it
|
||||
# possible to pass the provided values down the slots, e.g.:
|
||||
# possible to pass the provided values down through slots, e.g.:
|
||||
# {% provide "abc" val=123 %}
|
||||
# {% slot "content" %}{% endslot %}
|
||||
# {% endprovide %}
|
||||
|
@ -432,22 +518,42 @@ class SlotNode(BaseNode):
|
|||
|
||||
# For the user-provided slot fill, we want to use the context of where the slot
|
||||
# came from (or current context if configured so)
|
||||
used_ctx = self._resolve_slot_context(context, slot_fill)
|
||||
used_ctx = self._resolve_slot_context(context, slot_fill, component_ctx)
|
||||
with used_ctx.update(extra_context):
|
||||
# Required for compatibility with Django's {% extends %} tag
|
||||
# This makes sure that the render context used outside of a component
|
||||
# is the same as the one used inside the slot.
|
||||
# See https://github.com/django-components/django-components/pull/859
|
||||
render_ctx_layer = used_ctx.render_context.dicts[-2] if len(used_ctx.render_context.dicts) > 1 else {}
|
||||
if len(used_ctx.render_context.dicts) > 1 and "block_context" in used_ctx.render_context.dicts[-2]:
|
||||
render_ctx_layer = used_ctx.render_context.dicts[-2]
|
||||
else:
|
||||
# Otherwise we simply re-use the last layer, so that following logic uses `with` in either case
|
||||
render_ctx_layer = used_ctx.render_context.dicts[-1]
|
||||
|
||||
with used_ctx.render_context.push(render_ctx_layer):
|
||||
# Render slot as a function
|
||||
# NOTE: While `{% fill %}` tag has to opt in for the `default` and `data` variables,
|
||||
# the render function ALWAYS receives them.
|
||||
output = slot_fill.slot(used_ctx, kwargs, slot_ref)
|
||||
with add_slot_to_error_message(component_name, slot_name):
|
||||
# Render slot as a function
|
||||
# NOTE: While `{% fill %}` tag has to opt in for the `default` and `data` variables,
|
||||
# the render function ALWAYS receives them.
|
||||
output = slot_fill.slot(used_ctx, kwargs, slot_ref)
|
||||
|
||||
trace_component_msg(
|
||||
"RENDER_SLOT_END",
|
||||
component_name=component_name,
|
||||
component_id=component_id,
|
||||
slot_name=slot_name,
|
||||
component_path=component_path,
|
||||
slot_fills=slot_fills,
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
def _resolve_slot_context(self, context: Context, slot_fill: "SlotFill") -> Context:
|
||||
def _resolve_slot_context(
|
||||
self,
|
||||
context: Context,
|
||||
slot_fill: "SlotFill",
|
||||
component_ctx: "ComponentContext",
|
||||
) -> Context:
|
||||
"""Prepare the context used in a slot fill based on the settings."""
|
||||
# If slot is NOT filled, we use the slot's default AKA content between
|
||||
# the `{% slot %}` tags. These should be evaluated as if the `{% slot %}`
|
||||
|
@ -455,13 +561,14 @@ class SlotNode(BaseNode):
|
|||
if not slot_fill.is_filled:
|
||||
return context
|
||||
|
||||
registry: "ComponentRegistry" = context[_REGISTRY_CONTEXT_KEY]
|
||||
if registry.settings.context_behavior == ContextBehavior.DJANGO:
|
||||
registry_settings = component_ctx.registry.settings
|
||||
if registry_settings.context_behavior == ContextBehavior.DJANGO:
|
||||
return context
|
||||
elif registry.settings.context_behavior == ContextBehavior.ISOLATED:
|
||||
return context[_ROOT_CTX_CONTEXT_KEY]
|
||||
elif registry_settings.context_behavior == ContextBehavior.ISOLATED:
|
||||
outer_context = component_ctx.outer_context
|
||||
return outer_context if outer_context is not None else Context()
|
||||
else:
|
||||
raise ValueError(f"Unknown value for context_behavior: '{registry.settings.context_behavior}'")
|
||||
raise ValueError(f"Unknown value for context_behavior: '{registry_settings.context_behavior}'")
|
||||
|
||||
|
||||
class FillNode(BaseNode):
|
||||
|
@ -760,8 +867,9 @@ def resolve_fills(
|
|||
|
||||
if not nodelist_is_empty:
|
||||
slots[DEFAULT_SLOT_KEY] = _nodelist_to_slot_render_func(
|
||||
DEFAULT_SLOT_KEY,
|
||||
nodelist,
|
||||
component_name=component_name,
|
||||
slot_name=None, # Will be populated later
|
||||
nodelist=nodelist,
|
||||
data_var=None,
|
||||
default_var=None,
|
||||
)
|
||||
|
@ -772,6 +880,7 @@ def resolve_fills(
|
|||
# This is different from the default slot, where we ignore empty content.
|
||||
for fill in maybe_fills:
|
||||
slots[fill.name] = _nodelist_to_slot_render_func(
|
||||
component_name=component_name,
|
||||
slot_name=fill.name,
|
||||
nodelist=fill.fill.nodelist,
|
||||
data_var=fill.data_var,
|
||||
|
@ -843,7 +952,8 @@ def _escape_slot_name(name: str) -> str:
|
|||
|
||||
|
||||
def _nodelist_to_slot_render_func(
|
||||
slot_name: str,
|
||||
component_name: str,
|
||||
slot_name: Optional[str],
|
||||
nodelist: NodeList,
|
||||
data_var: Optional[str] = None,
|
||||
default_var: Optional[str] = None,
|
||||
|
@ -861,6 +971,13 @@ def _nodelist_to_slot_render_func(
|
|||
f"Slot default alias in fill '{slot_name}' must be a valid identifier. Got '{default_var}'"
|
||||
)
|
||||
|
||||
# We use Template.render() to render the nodelist, so that Django correctly sets up
|
||||
# and binds the context.
|
||||
template = Template("")
|
||||
template.nodelist = nodelist
|
||||
# This allows the template to access current RenderContext layer.
|
||||
template._djc_is_component_nested = True
|
||||
|
||||
def render_func(ctx: Context, slot_data: Dict[str, Any], slot_ref: SlotRef) -> SlotResult:
|
||||
# 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 %}`
|
||||
|
@ -887,34 +1004,43 @@ def _nodelist_to_slot_render_func(
|
|||
#
|
||||
# Thus, when we get here and `extra_context` is not None, it means that the component
|
||||
# is being rendered from within the template. And so we know that we're inside `Component._render()`.
|
||||
# And that means that the context MUST contain our internal context keys like `_ROOT_CTX_CONTEXT_KEY`.
|
||||
# And that means that the context MUST contain our internal context keys like `_COMPONENT_CONTEXT_KEY`.
|
||||
#
|
||||
# And so we want to put the `extra_context` into the same layer that contains `_ROOT_CTX_CONTEXT_KEY`.
|
||||
# And so we want to put the `extra_context` into the same layer that contains `_COMPONENT_CONTEXT_KEY`.
|
||||
#
|
||||
# HOWEVER, the layer with `_ROOT_CTX_CONTEXT_KEY` also contains user-defined data from `get_context_data()`.
|
||||
# HOWEVER, the layer with `_COMPONENT_CONTEXT_KEY` also contains user-defined data from `get_context_data()`.
|
||||
# Data from `get_context_data()` should take precedence over `extra_context`. So we have to insert
|
||||
# the forloop variables BEFORE that.
|
||||
index_of_last_component_layer = get_last_index(ctx.dicts, lambda d: _ROOT_CTX_CONTEXT_KEY in d)
|
||||
index_of_last_component_layer = get_last_index(ctx.dicts, lambda d: _COMPONENT_CONTEXT_KEY in d)
|
||||
if index_of_last_component_layer is None:
|
||||
index_of_last_component_layer = 0
|
||||
|
||||
# TODO: Currently there's one more layer before the `_ROOT_CTX_CONTEXT_KEY` layer, which is
|
||||
# TODO: Currently there's one more layer before the `_COMPONENT_CONTEXT_KEY` layer, which is
|
||||
# pushed in `_prepare_template()` in `component.py`.
|
||||
# That layer should be removed when `Component.get_template()` is removed, after which
|
||||
# the following line can be removed.
|
||||
index_of_last_component_layer -= 1
|
||||
|
||||
# Insert the `extra_context` into the correct layer of the context stack
|
||||
# Insert the `extra_context` layer BEFORE the layer that defines the variables from get_context_data.
|
||||
# Thus, get_context_data will overshadow these on conflict.
|
||||
ctx.dicts.insert(index_of_last_component_layer, extra_context or {})
|
||||
|
||||
rendered = nodelist.render(ctx)
|
||||
trace_component_msg("RENDER_NODELIST", component_name, component_id=None, slot_name=slot_name)
|
||||
|
||||
rendered = template.render(ctx)
|
||||
|
||||
# After the rendering is done, remove the `extra_context` from the context stack
|
||||
ctx.dicts.pop(index_of_last_component_layer)
|
||||
|
||||
return rendered
|
||||
|
||||
return Slot(content_func=cast(SlotFunc, render_func))
|
||||
return Slot(
|
||||
content_func=cast(SlotFunc, render_func),
|
||||
component_name=component_name,
|
||||
slot_name=slot_name,
|
||||
escaped=False,
|
||||
nodelist=nodelist,
|
||||
)
|
||||
|
||||
|
||||
def _is_extracting_fill(context: Context) -> bool:
|
||||
|
|
114
src/django_components/util/context.py
Normal file
114
src/django_components/util/context.py
Normal file
|
@ -0,0 +1,114 @@
|
|||
import copy
|
||||
from typing import List
|
||||
|
||||
from django.template import Context
|
||||
from django.template.loader_tags import BlockContext
|
||||
|
||||
|
||||
class CopiedDict(dict):
|
||||
"""Dict subclass to identify dictionaries that have been copied with `snapshot_context`"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def snapshot_context(context: Context) -> Context:
|
||||
"""
|
||||
Make a copy of the Context object, so that it can be used as a snapshot.
|
||||
|
||||
Snapshot here means that the copied Context can leave any scopes that the original
|
||||
Context is part of, and the copy will NOT be modified:
|
||||
|
||||
```py
|
||||
ctx = Context({ ... })
|
||||
with ctx.render_context.push({}):
|
||||
with ctx.update({"a": 1}):
|
||||
snapshot = snapshot_context(ctx)
|
||||
|
||||
assert snapshot["a"] == 1 # OK
|
||||
assert ctx["a"] == 1 # ERROR
|
||||
```
|
||||
|
||||
This function aims to make a shallow copy, but needs to make deeper copies
|
||||
for certain features like forloops or support for `{% block %}` / `{% extends %}`.
|
||||
"""
|
||||
# Using `copy()` should also copy flags like `autoescape`, `use_l10n`, etc.
|
||||
context_copy = copy.copy(context)
|
||||
|
||||
# Context is a list of dicts, where the dicts can be thought of as "layers" - when a new
|
||||
# layer is added, the keys defined on the latest layer overshadow the previous layers.
|
||||
#
|
||||
# For some special cases, like when we're inside forloops, we need to make deep copies
|
||||
# of the objects created by the forloop, so all forloop metadata (index, first, last, etc.)
|
||||
# is preserved for all (potentially nested) forloops.
|
||||
#
|
||||
# For non-forloop layers, we just make shallow copies.
|
||||
dicts_with_copied_forloops: List[CopiedDict] = []
|
||||
|
||||
# NOTE: For better performance, we iterate over the dicts in reverse order.
|
||||
# This is because:
|
||||
# 1. Layers are added to the end of the list.
|
||||
# 2. We assume that user won't be replacing the dicts in the older layers.
|
||||
# 3. Thus, when we come across a layer that has already been copied,
|
||||
# we know that all layers before it have also been copied.
|
||||
for ctx_dict_index in reversed(range(len(context.dicts))):
|
||||
ctx_dict = context.dicts[ctx_dict_index]
|
||||
|
||||
# This layer is already copied, reuse this and all before it
|
||||
if isinstance(ctx_dict, CopiedDict):
|
||||
# NOTE: +1 because we want to include the current layer
|
||||
dicts_with_copied_forloops = context.dicts[: ctx_dict_index + 1] + dicts_with_copied_forloops
|
||||
break
|
||||
|
||||
# Copy the dict
|
||||
ctx_dict_copy = CopiedDict(ctx_dict)
|
||||
if "forloop" in ctx_dict:
|
||||
ctx_dict_copy["forloop"] = ctx_dict["forloop"].copy()
|
||||
|
||||
# Recursively copy the state of potentially nested forloops
|
||||
curr_forloop = ctx_dict_copy["forloop"]
|
||||
while curr_forloop is not None:
|
||||
curr_forloop["parentloop"] = curr_forloop["parentloop"].copy()
|
||||
if "parentloop" in curr_forloop["parentloop"]:
|
||||
curr_forloop = curr_forloop["parentloop"]
|
||||
else:
|
||||
break
|
||||
|
||||
dicts_with_copied_forloops.insert(0, ctx_dict_copy)
|
||||
|
||||
context_copy.dicts = dicts_with_copied_forloops
|
||||
|
||||
# Make a copy of RenderContext
|
||||
render_ctx_copies: List[CopiedDict] = []
|
||||
for render_ctx_dict_index in reversed(range(len(context.render_context.dicts))):
|
||||
render_ctx_dict = context.render_context.dicts[render_ctx_dict_index]
|
||||
|
||||
# This layer is already copied, reuse this and all before it
|
||||
if isinstance(render_ctx_dict, CopiedDict):
|
||||
# NOTE: +1 because we want to include the current layer
|
||||
render_ctx_copies = context.render_context.dicts[: render_ctx_dict_index + 1] + render_ctx_copies
|
||||
break
|
||||
|
||||
# This holds info on what `{% block %}` blocks are defined
|
||||
render_ctx_dict_copy = CopiedDict(render_ctx_dict)
|
||||
if "block_context" in render_ctx_dict:
|
||||
render_ctx_dict_copy["block_context"] = _copy_block_context(render_ctx_dict["block_context"])
|
||||
|
||||
# "extends_context" is a list of Origin objects
|
||||
if "extends_context" in render_ctx_dict:
|
||||
render_ctx_dict_copy["extends_context"] = render_ctx_dict["extends_context"].copy()
|
||||
|
||||
render_ctx_dict_copy["_djc_snapshot"] = True
|
||||
render_ctx_copies.insert(0, render_ctx_dict_copy)
|
||||
|
||||
context_copy.render_context.dicts = render_ctx_copies
|
||||
return context_copy
|
||||
|
||||
|
||||
def _copy_block_context(block_context: BlockContext) -> BlockContext:
|
||||
"""Make a shallow copy of BlockContext"""
|
||||
block_context_copy = block_context.__class__()
|
||||
for key, val in block_context.blocks.items():
|
||||
# Individual dict values should be lists of Nodes. We don't
|
||||
# need to modify the Nodes, but we need to make a copy of the lists.
|
||||
block_context_copy.blocks[key] = val.copy()
|
||||
return block_context_copy
|
61
src/django_components/util/exception.py
Normal file
61
src/django_components/util/exception.py
Normal file
|
@ -0,0 +1,61 @@
|
|||
from contextlib import contextmanager
|
||||
from typing import Generator, List
|
||||
|
||||
|
||||
@contextmanager
|
||||
def component_error_message(component_path: List[str]) -> Generator[None, None, None]:
|
||||
"""
|
||||
If an error occurs within the context, format the error message to include
|
||||
the component path. E.g.
|
||||
```
|
||||
KeyError: "An error occured while rendering components MyPage > MyComponent > MyComponent(slot:content)
|
||||
```
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
except Exception as err:
|
||||
if not hasattr(err, "_components"):
|
||||
err._components = [] # type: ignore[attr-defined]
|
||||
|
||||
components = getattr(err, "_components", [])
|
||||
components = err._components = [*component_path, *components] # type: ignore[attr-defined]
|
||||
|
||||
# Access the exception's message, see https://stackoverflow.com/a/75549200/9788634
|
||||
if len(err.args) and err.args[0] is not None:
|
||||
if not components:
|
||||
orig_msg = str(err.args[0])
|
||||
else:
|
||||
orig_msg = err.args[0].split("\n", 1)[-1]
|
||||
else:
|
||||
orig_msg = str(err)
|
||||
|
||||
# Format component path as
|
||||
# "MyPage > MyComponent > MyComponent(slot:content) > Base(slot:tab)"
|
||||
comp_path = " > ".join(components)
|
||||
prefix = f"An error occured while rendering components {comp_path}:\n"
|
||||
|
||||
err.args = (prefix + orig_msg,) # tuple of one
|
||||
|
||||
# `from None` should still raise the original error, but without showing this
|
||||
# line in the traceback.
|
||||
raise err from None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def add_slot_to_error_message(component_name: str, slot_name: str) -> Generator[None, None, None]:
|
||||
"""
|
||||
This compliments `component_error_message` and is used inside SlotNode to add
|
||||
the slots to the component path in the error message, e.g.:
|
||||
|
||||
```
|
||||
KeyError: "An error occured while rendering components MyPage > MyComponent > MyComponent(slot:content)
|
||||
```
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
except Exception as err:
|
||||
if not hasattr(err, "_components"):
|
||||
err._components = [] # type: ignore[attr-defined]
|
||||
|
||||
err._components.insert(0, f"{component_name}(slot:{slot_name})") # type: ignore[attr-defined]
|
||||
raise err from None
|
|
@ -1,6 +1,6 @@
|
|||
import logging
|
||||
import sys
|
||||
from typing import Any, Dict, Literal
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
DEFAULT_TRACE_LEVEL_NUM = 5 # NOTE: MUST be lower than DEBUG which is 10
|
||||
|
||||
|
@ -29,7 +29,7 @@ def _get_log_levels() -> Dict[str, int]:
|
|||
return logging._nameToLevel.copy()
|
||||
|
||||
|
||||
def trace(logger: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
|
||||
def trace(message: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""
|
||||
TRACE level logger.
|
||||
|
||||
|
@ -61,20 +61,64 @@ def trace(logger: logging.Logger, message: str, *args: Any, **kwargs: Any) -> No
|
|||
logger.log(actual_trace_level_num, message, *args, **kwargs)
|
||||
|
||||
|
||||
def trace_msg(
|
||||
action: Literal["PARSE", "RENDR"],
|
||||
def trace_node_msg(
|
||||
action: Literal["PARSE", "RENDER"],
|
||||
node_type: str,
|
||||
node_id: str,
|
||||
msg: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
TRACE level logger with opinionated format for tracing interaction of components,
|
||||
nodes, and slots. Formats messages like so:
|
||||
TRACE level logger with opinionated format for tracing interaction of nodes.
|
||||
Formats messages like so:
|
||||
|
||||
`"PARSE slot ID 0088 ...Done!"`
|
||||
"""
|
||||
full_msg = f"{action} {node_type} ID {node_id} {msg}"
|
||||
action_normalized = action.ljust(6, " ")
|
||||
full_msg = f"{action_normalized} NODE {node_type} ID {node_id} {msg}"
|
||||
|
||||
# NOTE: When debugging tests during development, it may be easier to change
|
||||
# this to `print()`
|
||||
trace(logger, full_msg)
|
||||
trace(full_msg)
|
||||
|
||||
|
||||
def trace_component_msg(
|
||||
action: str,
|
||||
component_name: str,
|
||||
component_id: Optional[str],
|
||||
slot_name: Optional[str],
|
||||
component_path: Optional[List[str]] = None,
|
||||
slot_fills: Optional[Dict[str, Any]] = None,
|
||||
extra: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
TRACE level logger with opinionated format for tracing interaction of components
|
||||
and slots. Formats messages like so:
|
||||
|
||||
`"RENDER_SLOT COMPONENT 'component_name' SLOT: 'slot_name' FILLS: 'fill_name' PATH: Root > Child > Grandchild "`
|
||||
"""
|
||||
|
||||
if component_id:
|
||||
component_id_str = f"ID {component_id}"
|
||||
else:
|
||||
component_id_str = ""
|
||||
|
||||
if slot_name:
|
||||
slot_name_str = f"SLOT: '{slot_name}'"
|
||||
else:
|
||||
slot_name_str = ""
|
||||
|
||||
if component_path:
|
||||
component_path_str = "PATH: " + " > ".join(component_path)
|
||||
else:
|
||||
component_path_str = ""
|
||||
|
||||
if slot_fills:
|
||||
slot_fills_str = "FILLS: " + ", ".join(slot_fills.keys())
|
||||
else:
|
||||
slot_fills_str = ""
|
||||
|
||||
full_msg = f"{action} COMPONENT: '{component_name}' {component_id_str} {slot_name_str} {slot_fills_str} {component_path_str} {extra}" # noqa: E501
|
||||
|
||||
# NOTE: When debugging tests during development, it may be easier to change
|
||||
# this to `print()`
|
||||
trace(full_msg)
|
||||
|
|
|
@ -24,13 +24,6 @@ def gen_id() -> str:
|
|||
)
|
||||
|
||||
|
||||
def find_last_index(lst: List, predicate: Callable[[Any], bool]) -> Any:
|
||||
for r_idx, elem in enumerate(reversed(lst)):
|
||||
if predicate(elem):
|
||||
return len(lst) - 1 - r_idx
|
||||
return -1
|
||||
|
||||
|
||||
def is_str_wrapped_in_quotes(s: str) -> bool:
|
||||
return s.startswith(('"', "'")) and s[0] == s[-1] and len(s) >= 2
|
||||
|
||||
|
@ -66,7 +59,16 @@ def default(val: Optional[T], default: T) -> T:
|
|||
return val if val is not None else default
|
||||
|
||||
|
||||
def get_index(lst: List, key: Callable[[Any], bool]) -> Optional[int]:
|
||||
"""Get the index of the first item in the list that satisfies the key"""
|
||||
for i in range(len(lst)):
|
||||
if key(lst[i]):
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def get_last_index(lst: List, key: Callable[[Any], bool]) -> Optional[int]:
|
||||
"""Get the index of the last item in the list that satisfies the key"""
|
||||
for index, item in enumerate(reversed(lst)):
|
||||
if key(item):
|
||||
return len(lst) - 1 - index
|
||||
|
|
|
@ -153,7 +153,7 @@ class ComponentTest(BaseTestCase):
|
|||
pass
|
||||
|
||||
with self.assertRaises(ImproperlyConfigured):
|
||||
EmptyComponent("empty_component")._get_template(Context({}))
|
||||
EmptyComponent("empty_component")._get_template(Context({}), "123")
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_template_string_static_inlined(self):
|
||||
|
@ -425,7 +425,7 @@ class ComponentTest(BaseTestCase):
|
|||
|
||||
with self.assertRaisesMessage(
|
||||
TypeError,
|
||||
"An error occured while rendering components Root > parent > provider > broken:\n"
|
||||
"An error occured while rendering components Root > parent > provider > provider(slot:content) > broken:\n"
|
||||
"tuple indices must be integers or slices, not str",
|
||||
):
|
||||
Root.render()
|
||||
|
|
|
@ -513,7 +513,7 @@ class OuterContextPropertyTests(BaseTestCase):
|
|||
"""
|
||||
|
||||
def get_context_data(self):
|
||||
return self.outer_context.flatten()
|
||||
return self.outer_context.flatten() # type: ignore[union-attr]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
|
|
@ -508,7 +508,7 @@ class MiddlewareTests(BaseTestCase):
|
|||
|
||||
assert_dependencies(rendered1)
|
||||
self.assertEqual(
|
||||
rendered1.count('Variable: <strong data-djc-id-a1bc41="" data-djc-id-a1bc42="">value</strong>'),
|
||||
rendered1.count('Variable: <strong data-djc-id-a1bc42="" data-djc-id-a1bc41="">value</strong>'),
|
||||
1,
|
||||
)
|
||||
|
||||
|
@ -519,7 +519,7 @@ class MiddlewareTests(BaseTestCase):
|
|||
|
||||
assert_dependencies(rendered2)
|
||||
self.assertEqual(
|
||||
rendered2.count('Variable: <strong data-djc-id-a1bc43="" data-djc-id-a1bc44="">value</strong>'),
|
||||
rendered2.count('Variable: <strong data-djc-id-a1bc44="" data-djc-id-a1bc43="">value</strong>'),
|
||||
1,
|
||||
)
|
||||
|
||||
|
@ -530,6 +530,6 @@ class MiddlewareTests(BaseTestCase):
|
|||
|
||||
assert_dependencies(rendered3)
|
||||
self.assertEqual(
|
||||
rendered3.count('Variable: <strong data-djc-id-a1bc45="" data-djc-id-a1bc46="">value</strong>'),
|
||||
rendered3.count('Variable: <strong data-djc-id-a1bc46="" data-djc-id-a1bc45="">value</strong>'),
|
||||
1,
|
||||
)
|
||||
|
|
|
@ -658,9 +658,9 @@ class SpreadOperatorTests(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc40>{'@click': '() => {}', 'style': 'height: 20px'}</div>
|
||||
<div data-djc-id-a1bc40>[1, 2, 3]</div>
|
||||
<div data-djc-id-a1bc40>1</div>
|
||||
<div data-djc-id-a1bc41>{'@click': '() => {}', 'style': 'height: 20px'}</div>
|
||||
<div data-djc-id-a1bc41>[1, 2, 3]</div>
|
||||
<div data-djc-id-a1bc41>1</div>
|
||||
""",
|
||||
)
|
||||
|
||||
|
|
|
@ -38,8 +38,8 @@ class TemplateCacheTest(BaseTestCase):
|
|||
}
|
||||
|
||||
comp = SimpleComponent()
|
||||
template_1 = comp._get_template(Context({}))
|
||||
template_1 = comp._get_template(Context({}), component_id="123")
|
||||
template_1._test_id = "123"
|
||||
|
||||
template_2 = comp._get_template(Context({}))
|
||||
template_2 = comp._get_template(Context({}), component_id="123")
|
||||
self.assertEqual(template_2._test_id, "123")
|
||||
|
|
|
@ -752,6 +752,32 @@ class AggregateInputTests(BaseTestCase):
|
|||
)
|
||||
|
||||
|
||||
class RecursiveComponentTests(BaseTestCase):
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_recursive_component(self):
|
||||
DEPTH = 100
|
||||
|
||||
@register("recursive")
|
||||
class Recursive(Component):
|
||||
def get_context_data(self, depth: int = 0):
|
||||
print("depth:", depth)
|
||||
return {"depth": depth + 1, "DEPTH": DEPTH}
|
||||
|
||||
template: types.django_html = """
|
||||
<div>
|
||||
<span> depth: {{ depth }} </span>
|
||||
{% if depth <= DEPTH %}
|
||||
{% component "recursive" depth=depth / %}
|
||||
{% endif %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
result = Recursive.render()
|
||||
|
||||
for i in range(DEPTH):
|
||||
self.assertIn(f"<span> depth: {i + 1} </span>", result)
|
||||
|
||||
|
||||
class ComponentTemplateSyntaxErrorTests(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
|
|
@ -557,7 +557,6 @@ class ExtendsCompatTests(BaseTestCase):
|
|||
|
||||
template: types.django_html = """
|
||||
{% extends "block_in_component.html" %}
|
||||
{% load component_tags %}
|
||||
{% block body %}
|
||||
<div>
|
||||
58 giraffes and 2 pantaloons
|
||||
|
@ -826,10 +825,10 @@ class ExtendsCompatTests(BaseTestCase):
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<body>
|
||||
<custom-template data-djc-id-a1bc43>
|
||||
<custom-template data-djc-id-a1bc44>
|
||||
<header></header>
|
||||
<main>
|
||||
<div data-djc-id-a1bc47> injected: DepInject(hello='from_block') </div>
|
||||
<div data-djc-id-a1bc48> injected: DepInject(hello='from_block') </div>
|
||||
</main>
|
||||
<footer>Default footer</footer>
|
||||
</custom-template>
|
||||
|
|
|
@ -3,6 +3,7 @@ from typing import Any
|
|||
from django.template import Context, Template, TemplateSyntaxError
|
||||
|
||||
from django_components import Component, register, types
|
||||
from django_components.perfutil.provide import provide_cache, provide_references, all_reference_ids
|
||||
|
||||
from .django_test_setup import setup_test_config
|
||||
from .testutils import BaseTestCase, parametrize_context_behavior
|
||||
|
@ -11,6 +12,11 @@ setup_test_config({"autodiscover": False})
|
|||
|
||||
|
||||
class ProvideTemplateTagTest(BaseTestCase):
|
||||
def _assert_clear_cache(self):
|
||||
self.assertEqual(provide_cache, {})
|
||||
self.assertEqual(provide_references, {})
|
||||
self.assertEqual(all_reference_ids, set())
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_basic(self):
|
||||
@register("injectee")
|
||||
|
@ -25,7 +31,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" another=123 %}
|
||||
{% provide "my_provide" key="hi" another=1 %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
{% endprovide %}
|
||||
|
@ -36,16 +42,17 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc40> injected: DepInject(key='hi', another=123) </div>
|
||||
<div data-djc-id-a1bc41> injected: DepInject(key='hi', another=1) </div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_basic_self_closing(self):
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div>
|
||||
{% provide "my_provide" key="hi" another=123 / %}
|
||||
{% provide "my_provide" key="hi" another=2 / %}
|
||||
</div>
|
||||
"""
|
||||
template = Template(template_str)
|
||||
|
@ -57,6 +64,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
<div></div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_access_keys_in_python(self):
|
||||
|
@ -76,7 +84,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" another=123 %}
|
||||
{% provide "my_provide" key="hi" another=3 %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
{% endprovide %}
|
||||
|
@ -87,10 +95,11 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc40> key: hi </div>
|
||||
<div data-djc-id-a1bc40> another: 123 </div>
|
||||
<div data-djc-id-a1bc41> key: hi </div>
|
||||
<div data-djc-id-a1bc41> another: 3 </div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_access_keys_in_django(self):
|
||||
|
@ -109,7 +118,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" another=123 %}
|
||||
{% provide "my_provide" key="hi" another=4 %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
{% endprovide %}
|
||||
|
@ -120,10 +129,11 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc40> key: hi </div>
|
||||
<div data-djc-id-a1bc40> another: 123 </div>
|
||||
<div data-djc-id-a1bc41> key: hi </div>
|
||||
<div data-djc-id-a1bc41> another: 4 </div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_does_not_leak(self):
|
||||
|
@ -139,7 +149,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" another=123 %}
|
||||
{% provide "my_provide" key="hi" another=5 %}
|
||||
{% endprovide %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
|
@ -150,9 +160,10 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc40> injected: default </div>
|
||||
<div data-djc-id-a1bc41> injected: default </div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_empty(self):
|
||||
|
@ -183,12 +194,13 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc41> injected: DepInject() </div>
|
||||
<div data-djc-id-a1bc42> injected: default </div>
|
||||
<div data-djc-id-a1bc42> injected: DepInject() </div>
|
||||
<div data-djc-id-a1bc43> injected: default </div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
@parametrize_context_behavior(["django"])
|
||||
def test_provide_no_inject(self):
|
||||
"""Check that nothing breaks if we do NOT inject even if some data is provided"""
|
||||
|
||||
|
@ -203,7 +215,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" another=123 %}
|
||||
{% provide "my_provide" key="hi" another=6 %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
{% endprovide %}
|
||||
|
@ -216,10 +228,11 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc41></div>
|
||||
<div data-djc-id-a1bc42></div>
|
||||
<div data-djc-id-a1bc43></div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_name_single_quotes(self):
|
||||
|
@ -235,7 +248,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide 'my_provide' key="hi" another=123 %}
|
||||
{% provide 'my_provide' key="hi" another=7 %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
{% endprovide %}
|
||||
|
@ -248,10 +261,11 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc41> injected: DepInject(key='hi', another=123) </div>
|
||||
<div data-djc-id-a1bc42> injected: default </div>
|
||||
<div data-djc-id-a1bc42> injected: DepInject(key='hi', another=7) </div>
|
||||
<div data-djc-id-a1bc43> injected: default </div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_name_as_var(self):
|
||||
|
@ -267,7 +281,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide var_a key="hi" another=123 %}
|
||||
{% provide var_a key="hi" another=8 %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
{% endprovide %}
|
||||
|
@ -286,10 +300,11 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc41> injected: DepInject(key='hi', another=123) </div>
|
||||
<div data-djc-id-a1bc42> injected: default </div>
|
||||
<div data-djc-id-a1bc42> injected: DepInject(key='hi', another=8) </div>
|
||||
<div data-djc-id-a1bc43> injected: default </div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_name_as_spread(self):
|
||||
|
@ -319,7 +334,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
"provide_props": {
|
||||
"name": "my_provide",
|
||||
"key": "hi",
|
||||
"another": 123,
|
||||
"another": 9,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
@ -328,10 +343,11 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc41> injected: DepInject(key='hi', another=123) </div>
|
||||
<div data-djc-id-a1bc42> injected: default </div>
|
||||
<div data-djc-id-a1bc42> injected: DepInject(key='hi', another=9) </div>
|
||||
<div data-djc-id-a1bc43> injected: default </div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_no_name_raises(self):
|
||||
|
@ -347,7 +363,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide key="hi" another=123 %}
|
||||
{% provide key="hi" another=10 %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
{% endprovide %}
|
||||
|
@ -360,6 +376,8 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
):
|
||||
Template(template_str).render(Context({}))
|
||||
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_name_must_be_string_literal(self):
|
||||
@register("injectee")
|
||||
|
@ -374,7 +392,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide my_var key="hi" another=123 %}
|
||||
{% provide my_var key="hi" another=11 %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
{% endprovide %}
|
||||
|
@ -387,6 +405,8 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
):
|
||||
Template(template_str).render(Context({}))
|
||||
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_name_must_be_identifier(self):
|
||||
@register("injectee")
|
||||
|
@ -401,7 +421,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "%heya%" key="hi" another=123 %}
|
||||
{% provide "%heya%" key="hi" another=12 %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
{% endprovide %}
|
||||
|
@ -409,8 +429,10 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
{% endcomponent %}
|
||||
"""
|
||||
template = Template(template_str)
|
||||
|
||||
with self.assertRaises(TemplateSyntaxError):
|
||||
template.render(Context({}))
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_aggregate_dics(self):
|
||||
|
@ -426,7 +448,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" var1:key="hi" var1:another=123 var2:x="y" %}
|
||||
{% provide "my_provide" var1:key="hi" var1:another=13 var2:x="y" %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
{% endprovide %}
|
||||
|
@ -437,9 +459,10 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc40> injected: DepInject(var1={'key': 'hi', 'another': 123}, var2={'x': 'y'}) </div>
|
||||
<div data-djc-id-a1bc41> injected: DepInject(var1={'key': 'hi', 'another': 13}, var2={'x': 'y'}) </div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_does_not_expose_kwargs_to_context(self):
|
||||
|
@ -459,7 +482,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
{% load component_tags %}
|
||||
var_out: {{ var }}
|
||||
key_out: {{ key }}
|
||||
{% provide "my_provide" key="hi" another=123 %}
|
||||
{% provide "my_provide" key="hi" another=14 %}
|
||||
var_in: {{ var }}
|
||||
key_in: {{ key }}
|
||||
{% endprovide %}
|
||||
|
@ -476,6 +499,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
key_in:
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_nested_in_provide_same_key(self):
|
||||
|
@ -493,8 +517,8 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" another=123 lost=0 %}
|
||||
{% provide "my_provide" key="hi1" another=1231 new=3 %}
|
||||
{% provide "my_provide" key="hi" another=15 lost=0 %}
|
||||
{% provide "my_provide" key="hi1" another=16 new=3 %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
{% endprovide %}
|
||||
|
@ -511,12 +535,14 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc43> injected: DepInject(key='hi1', another=1231, new=3) </div>
|
||||
<div data-djc-id-a1bc44> injected: DepInject(key='hi', another=123, lost=0) </div>
|
||||
<div data-djc-id-a1bc45> injected: default </div>
|
||||
<div data-djc-id-a1bc45> injected: DepInject(key='hi1', another=16, new=3) </div>
|
||||
<div data-djc-id-a1bc46> injected: DepInject(key='hi', another=15, lost=0) </div>
|
||||
<div data-djc-id-a1bc47> injected: default </div>
|
||||
""",
|
||||
)
|
||||
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_nested_in_provide_different_key(self):
|
||||
"""Check that `provide` tag with different keys don't affect each other"""
|
||||
|
@ -538,8 +564,8 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "first_provide" key="hi" another=123 lost=0 %}
|
||||
{% provide "second_provide" key="hi1" another=1231 new=3 %}
|
||||
{% provide "first_provide" key="hi" another=17 lost=0 %}
|
||||
{% provide "second_provide" key="hi1" another=18 new=3 %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
{% endprovide %}
|
||||
|
@ -551,10 +577,11 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc41> first_provide: DepInject(key='hi', another=123, lost=0) </div>
|
||||
<div data-djc-id-a1bc41> second_provide: DepInject(key='hi1', another=1231, new=3) </div>
|
||||
<div data-djc-id-a1bc43> first_provide: DepInject(key='hi', another=17, lost=0) </div>
|
||||
<div data-djc-id-a1bc43> second_provide: DepInject(key='hi1', another=18, new=3) </div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_in_include(self):
|
||||
|
@ -570,7 +597,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" another=123 %}
|
||||
{% provide "my_provide" key="hi" another=19 %}
|
||||
{% include "inject.html" %}
|
||||
{% endprovide %}
|
||||
"""
|
||||
|
@ -581,10 +608,11 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
rendered,
|
||||
"""
|
||||
<div>
|
||||
<div data-djc-id-a1bc40> injected: DepInject(key='hi', another=123) </div>
|
||||
<div data-djc-id-a1bc41> injected: DepInject(key='hi', another=19) </div>
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_slot_in_provide(self):
|
||||
|
@ -602,7 +630,7 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
class ParentComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" another=123 %}
|
||||
{% provide "my_provide" key="hi" another=20 %}
|
||||
{% slot "content" default %}{% endslot %}
|
||||
{% endprovide %}
|
||||
"""
|
||||
|
@ -619,14 +647,20 @@ class ProvideTemplateTagTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc40 data-djc-id-a1bc43>
|
||||
injected: DepInject(key='hi', another=123)
|
||||
<div data-djc-id-a1bc40 data-djc-id-a1bc44>
|
||||
injected: DepInject(key='hi', another=20)
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
|
||||
class InjectTest(BaseTestCase):
|
||||
def _assert_clear_cache(self):
|
||||
self.assertEqual(provide_cache, {})
|
||||
self.assertEqual(provide_references, {})
|
||||
self.assertEqual(all_reference_ids, set())
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_inject_basic(self):
|
||||
@register("injectee")
|
||||
|
@ -641,7 +675,7 @@ class InjectTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" another=123 %}
|
||||
{% provide "my_provide" key="hi" another=21 %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
{% endprovide %}
|
||||
|
@ -652,9 +686,10 @@ class InjectTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc40> injected: DepInject(key='hi', another=123) </div>
|
||||
<div data-djc-id-a1bc41> injected: DepInject(key='hi', another=21) </div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_inject_missing_key_raises_without_default(self):
|
||||
|
@ -678,6 +713,8 @@ class InjectTest(BaseTestCase):
|
|||
with self.assertRaises(KeyError):
|
||||
template.render(Context({}))
|
||||
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_inject_missing_key_ok_with_default(self):
|
||||
@register("injectee")
|
||||
|
@ -703,6 +740,7 @@ class InjectTest(BaseTestCase):
|
|||
<div data-djc-id-a1bc3f> injected: default </div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_inject_empty_string(self):
|
||||
|
@ -718,7 +756,7 @@ class InjectTest(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" another=123 %}
|
||||
{% provide "my_provide" key="hi" another=22 %}
|
||||
{% component "injectee" %}
|
||||
{% endcomponent %}
|
||||
{% endprovide %}
|
||||
|
@ -730,6 +768,8 @@ class InjectTest(BaseTestCase):
|
|||
with self.assertRaises(KeyError):
|
||||
template.render(Context({}))
|
||||
|
||||
self._assert_clear_cache()
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_inject_raises_on_called_outside_get_context_data(self):
|
||||
@register("injectee")
|
||||
|
@ -746,6 +786,8 @@ class InjectTest(BaseTestCase):
|
|||
with self.assertRaises(RuntimeError):
|
||||
comp.inject("abc", "def")
|
||||
|
||||
self._assert_clear_cache()
|
||||
|
||||
# See https://github.com/django-components/django-components/pull/778
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_inject_in_fill(self):
|
||||
|
@ -805,14 +847,15 @@ class InjectTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc45 data-djc-id-a1bc48>
|
||||
<div data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc45 data-djc-id-a1bc49>
|
||||
injected: DepInject(key='hi', data=123)
|
||||
</div>
|
||||
<main data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc45 data-djc-id-a1bc48>
|
||||
<main data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc45 data-djc-id-a1bc49>
|
||||
456
|
||||
</main>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
# See https://github.com/django-components/django-components/pull/786
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
|
@ -869,10 +912,173 @@ class InjectTest(BaseTestCase):
|
|||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc44 data-djc-id-a1bc47>
|
||||
<div data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc44 data-djc-id-a1bc48>
|
||||
injected: DepInject(key='hi', data=123)
|
||||
</div>
|
||||
<main data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc44 data-djc-id-a1bc47>
|
||||
<main data-djc-id-a1bc3e data-djc-id-a1bc41 data-djc-id-a1bc44 data-djc-id-a1bc48>
|
||||
</main>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
|
||||
# When there is `{% component %}` that's a descendant of `{% provide %}`,
|
||||
# then the cache entry is NOT removed as soon as we have rendered the children (nodelist)
|
||||
# of `{% provide %}`.
|
||||
#
|
||||
# Instead, we manage the state ourselves, and remove the cache entry
|
||||
# when the component rendered is done.
|
||||
class ProvideCacheTest(BaseTestCase):
|
||||
def _assert_clear_cache(self):
|
||||
self.assertEqual(provide_cache, {})
|
||||
self.assertEqual(provide_references, {})
|
||||
self.assertEqual(all_reference_ids, set())
|
||||
|
||||
def test_provide_outside_component(self):
|
||||
tester = self
|
||||
|
||||
@register("injectee")
|
||||
class Injectee(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div> injected: {{ data|safe }} </div>
|
||||
<div> Ran: {{ ran }} </div>
|
||||
"""
|
||||
|
||||
def get_context_data(self):
|
||||
tester.assertEqual(len(provide_cache), 1)
|
||||
|
||||
data = self.inject("my_provide")
|
||||
return {"data": data, "ran": True}
|
||||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" another=23 %}
|
||||
{% component "injectee" / %}
|
||||
{% endprovide %}
|
||||
"""
|
||||
|
||||
self._assert_clear_cache()
|
||||
|
||||
template = Template(template_str)
|
||||
self._assert_clear_cache()
|
||||
|
||||
rendered = template.render(Context({}))
|
||||
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc41>
|
||||
injected: DepInject(key='hi', another=23)
|
||||
</div>
|
||||
<div data-djc-id-a1bc41>
|
||||
Ran: True
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
# Cache should be cleared even if there is an error.
|
||||
def test_provide_outside_component_with_error(self):
|
||||
tester = self
|
||||
|
||||
@register("injectee")
|
||||
class Injectee(Component):
|
||||
template = ""
|
||||
|
||||
def get_context_data(self):
|
||||
tester.assertEqual(len(provide_cache), 1)
|
||||
data = self.inject("my_provide")
|
||||
|
||||
raise ValueError("Oops")
|
||||
return {"data": data, "ran": True}
|
||||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" another=24 %}
|
||||
{% component "injectee" / %}
|
||||
{% endprovide %}
|
||||
"""
|
||||
|
||||
self._assert_clear_cache()
|
||||
|
||||
template = Template(template_str)
|
||||
self._assert_clear_cache()
|
||||
|
||||
with self.assertRaisesMessage(ValueError, "Oops"):
|
||||
template.render(Context({}))
|
||||
|
||||
self._assert_clear_cache()
|
||||
|
||||
def test_provide_inside_component(self):
|
||||
tester = self
|
||||
|
||||
@register("injectee")
|
||||
class Injectee(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div> injected: {{ data|safe }} </div>
|
||||
<div> Ran: {{ ran }} </div>
|
||||
"""
|
||||
|
||||
def get_context_data(self):
|
||||
tester.assertEqual(len(provide_cache), 1)
|
||||
|
||||
data = self.inject("my_provide")
|
||||
return {"data": data, "ran": True}
|
||||
|
||||
@register("root")
|
||||
class Root(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" another=25 %}
|
||||
{% component "injectee" / %}
|
||||
{% endprovide %}
|
||||
"""
|
||||
|
||||
self._assert_clear_cache()
|
||||
|
||||
rendered = Root.render()
|
||||
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div data-djc-id-a1bc3e data-djc-id-a1bc42>
|
||||
injected: DepInject(key='hi', another=25)
|
||||
</div>
|
||||
<div data-djc-id-a1bc3e data-djc-id-a1bc42>
|
||||
Ran: True
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
self._assert_clear_cache()
|
||||
|
||||
def test_provide_inside_component_with_error(self):
|
||||
tester = self
|
||||
|
||||
@register("injectee")
|
||||
class Injectee(Component):
|
||||
template = ""
|
||||
|
||||
def get_context_data(self):
|
||||
tester.assertEqual(len(provide_cache), 1)
|
||||
|
||||
data = self.inject("my_provide")
|
||||
raise ValueError("Oops")
|
||||
return {"data": data, "ran": True}
|
||||
|
||||
@register("root")
|
||||
class Root(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide "my_provide" key="hi" another=26 %}
|
||||
{% component "injectee" / %}
|
||||
{% endprovide %}
|
||||
"""
|
||||
|
||||
self._assert_clear_cache()
|
||||
|
||||
with self.assertRaisesMessage(ValueError, "Oops"):
|
||||
Root.render()
|
||||
|
||||
self._assert_clear_cache()
|
||||
|
|
|
@ -531,7 +531,7 @@ class ComponentSlotTests(BaseTestCase):
|
|||
|
||||
with self.assertRaisesMessage(
|
||||
TemplateSyntaxError,
|
||||
"Encountered a SlotNode outside of a ComponentNode context.",
|
||||
"Encountered a SlotNode outside of a Component context.",
|
||||
):
|
||||
Template(template_str).render(Context({}))
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ from typing import Any, Dict, Optional
|
|||
|
||||
from django.template import Context, Template
|
||||
|
||||
from django_components import Component, registry, types
|
||||
from django_components import Component, register, registry, types
|
||||
|
||||
from .django_test_setup import setup_test_config
|
||||
from .testutils import BaseTestCase, parametrize_context_behavior
|
||||
|
@ -604,38 +604,10 @@ class ComponentNestingTests(BaseTestCase):
|
|||
</div>
|
||||
"""
|
||||
|
||||
class ComplexChildComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div>
|
||||
{% slot "content" default %}
|
||||
No slot!
|
||||
{% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
class ComplexParentComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
ITEMS: {{ items|safe }}
|
||||
{% for item in items %}
|
||||
<li>
|
||||
{% component "complex_child" %}
|
||||
{{ item.value }}
|
||||
{% endcomponent %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
def get_context_data(self, items, *args, **kwargs) -> Dict[str, Any]:
|
||||
return {"items": items}
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
registry.register("dashboard", self.DashboardComponent)
|
||||
registry.register("calendar", self.CalendarComponent)
|
||||
registry.register("complex_child", self.ComplexChildComponent)
|
||||
registry.register("complex_parent", self.ComplexParentComponent)
|
||||
|
||||
# NOTE: Second arg in tuple are expected names in nested fills. In "django" mode,
|
||||
# the value should be overridden by the component, while in "isolated" it should
|
||||
|
@ -770,6 +742,34 @@ class ComponentNestingTests(BaseTestCase):
|
|||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_component_nesting_deep_slot_inside_component_fill(self):
|
||||
@register("complex_child")
|
||||
class ComplexChildComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
<div>
|
||||
{% slot "content" default %}
|
||||
No slot!
|
||||
{% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
@register("complex_parent")
|
||||
class ComplexParentComponent(Component):
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
ITEMS: {{ items|safe }}
|
||||
{% for item in items %}
|
||||
<li>
|
||||
{% component "complex_child" %}
|
||||
{{ item.value }}
|
||||
{% endcomponent %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
def get_context_data(self, items, *args, **kwargs) -> Dict[str, Any]:
|
||||
return {"items": items}
|
||||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% component "complex_parent" items=items %}{% endcomponent %}
|
||||
|
@ -792,6 +792,114 @@ class ComponentNestingTests(BaseTestCase):
|
|||
"""
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
# This test is based on real-life example.
|
||||
# It ensures that deeply nested slots in fills with same names are resolved correctly.
|
||||
# It also ensures that the component_vars.is_filled context is correctly populated.
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_component_nesting_deep_slot_inside_component_fill_2(self):
|
||||
@register("TestPage")
|
||||
class TestPage(Component):
|
||||
template: types.django_html = """
|
||||
{% component "TestLayout" %}
|
||||
{% fill "content" %}
|
||||
<div class="test-page">
|
||||
------PROJ_LAYOUT_TABBED META ------<br/>
|
||||
component_vars.is_filled: {{ component_vars.is_filled|safe }}<br/>
|
||||
------------------------<br/>
|
||||
|
||||
{% if component_vars.is_filled.left_panel %}
|
||||
<div style="background-color: red;">
|
||||
{% slot "left_panel" / %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% slot "content" default %}
|
||||
DEFAULT LAYOUT TABBED CONTENT
|
||||
{% endslot %}
|
||||
</div>
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
|
||||
@register("TestLayout")
|
||||
class TestLayout(Component):
|
||||
template: types.django_html = """
|
||||
{% component "TestBase" %}
|
||||
{% fill "content" %}
|
||||
------LAYOUT META ------<br/>
|
||||
component_vars.is_filled: {{ component_vars.is_filled|safe }}<br/>
|
||||
------------------------<br/>
|
||||
|
||||
<div class="test-layout">
|
||||
{% slot "content" default / %}
|
||||
</div>
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
"""
|
||||
|
||||
@register("TestBase")
|
||||
class TestBase(Component):
|
||||
template: types.django_html = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test app</title>
|
||||
</head>
|
||||
<body class="test-base">
|
||||
------BASE META ------<br/>
|
||||
component_vars.is_filled: {{ component_vars.is_filled|safe }}<br/>
|
||||
------------------------<br/>
|
||||
|
||||
{% slot "content" default / %}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
rendered = TestPage.render(
|
||||
slots={
|
||||
"left_panel": "LEFT PANEL SLOT FROM FILL",
|
||||
"content": "CONTENT SLOT FROM FILL",
|
||||
}
|
||||
)
|
||||
|
||||
expected = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-djc-id-a1bc3e data-djc-id-a1bc43 data-djc-id-a1bc47><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test app</title>
|
||||
</head>
|
||||
<body class="test-base">
|
||||
------BASE META ------ <br>
|
||||
component_vars.is_filled: {'content': True}<br>
|
||||
------------------------ <br>
|
||||
|
||||
------LAYOUT META ------<br>
|
||||
component_vars.is_filled: {'content': True}<br>
|
||||
------------------------<br>
|
||||
|
||||
<div class="test-layout">
|
||||
<div class="test-page">
|
||||
------PROJ_LAYOUT_TABBED META ------<br>
|
||||
component_vars.is_filled: {'left_panel': True, 'content': True}<br>
|
||||
------------------------<br>
|
||||
|
||||
<div style="background-color: red;">
|
||||
LEFT PANEL SLOT FROM FILL
|
||||
</div>
|
||||
CONTENT SLOT FROM FILL
|
||||
</div>
|
||||
</div>
|
||||
<script src="django_components/django_components.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.assertHTMLEqual(rendered, expected)
|
||||
|
||||
# NOTE: Second arg in tuple is expected list content. In isolated mode, loops should NOT leak.
|
||||
@parametrize_context_behavior(
|
||||
[
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue