mirror of
https://github.com/django-components/django-components.git
synced 2025-09-11 16:36:17 +00:00
* Add 'default' slot option + implicit fills; tests; docs * Differentiate between standard fillnodes and implicitfillnodes on type lvl * Reworking slot-fill rendering logic. Simplifying component interfact. Add new get_string_template method * First working implementation of chainmap instead of stacks for slot resolution * Stop passing FillNode to Component initalizer -> better decoupling * Treat fill name and alias and component name as filterexpression, dropping namedvariable * Name arg of if_filled tags and slots must be string literal
This commit is contained in:
parent
349e9fe65f
commit
2d86f042da
11 changed files with 843 additions and 422 deletions
|
@ -1,38 +1,35 @@
|
|||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
ClassVar,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
TypeVar,
|
||||
)
|
||||
from collections import ChainMap
|
||||
from typing import Any, ClassVar, Dict, Iterable, Optional, Tuple, Union
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.forms.widgets import MediaDefiningClass
|
||||
from django.template import Context, TemplateSyntaxError
|
||||
from django.template.base import Node, NodeList, Template
|
||||
from django.forms.widgets import Media, MediaDefiningClass
|
||||
from django.template.base import NodeList, Template
|
||||
from django.template.context import Context
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.template.loader import get_template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
# Allow "component.AlreadyRegistered" instead of having to import these everywhere
|
||||
from django_components.component_registry import ( # noqa
|
||||
# Global registry var and register() function moved to separate module.
|
||||
# Defining them here made little sense, since 1) component_tags.py and component.py
|
||||
# rely on them equally, and 2) it made it difficult to avoid circularity in the
|
||||
# way the two modules depend on one another.
|
||||
from django_components.component_registry import ( # NOQA
|
||||
AlreadyRegistered,
|
||||
ComponentRegistry,
|
||||
NotRegistered,
|
||||
register,
|
||||
registry,
|
||||
)
|
||||
from django_components.templatetags.component_tags import (
|
||||
FILLED_SLOTS_CONTENT_CONTEXT_KEY,
|
||||
DefaultFillContent,
|
||||
FillContent,
|
||||
FilledSlotsContext,
|
||||
IfSlotFilledConditionBranchNode,
|
||||
NamedFillContent,
|
||||
SlotName,
|
||||
SlotNode,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django_components.templatetags.component_tags import (
|
||||
FillNode,
|
||||
SlotNode,
|
||||
)
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
FILLED_SLOTS_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
|
||||
|
||||
|
||||
class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
|
||||
|
@ -65,23 +62,41 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
|
|||
# Must be set on subclass OR subclass must implement get_template_name() with
|
||||
# non-null return.
|
||||
template_name: ClassVar[str]
|
||||
media: Media
|
||||
|
||||
def __init__(self, component_name):
|
||||
self._component_name: str = component_name
|
||||
self._instance_fills: Optional[List["FillNode"]] = None
|
||||
self._outer_context: Optional[dict] = None
|
||||
class Media:
|
||||
css = {}
|
||||
js = []
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
registered_name: Optional[str] = None,
|
||||
outer_context: Optional[Context] = None,
|
||||
fill_content: Union[
|
||||
DefaultFillContent, Iterable[NamedFillContent]
|
||||
] = (),
|
||||
):
|
||||
self.registered_name: Optional[str] = registered_name
|
||||
self.outer_context: Context = outer_context or Context()
|
||||
self.fill_content = fill_content
|
||||
|
||||
def get_context_data(self, *args, **kwargs) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
# Can be overridden for dynamic templates
|
||||
def get_template_name(self, context):
|
||||
if not hasattr(self, "template_name") or not self.template_name:
|
||||
def get_template_name(self, context) -> str:
|
||||
try:
|
||||
name = self.template_name
|
||||
except AttributeError:
|
||||
raise ImproperlyConfigured(
|
||||
f"Template name is not set for Component {self.__class__.__name__}"
|
||||
f"Template name is not set for Component {type(self).__name__}. "
|
||||
f"Note: this attribute is not required if you are overriding any of "
|
||||
f"the class's `get_template*()` methods."
|
||||
)
|
||||
return name
|
||||
|
||||
return self.template_name
|
||||
def get_template_string(self, context) -> str:
|
||||
...
|
||||
|
||||
def render_dependencies(self):
|
||||
"""Helper function to access media.render()"""
|
||||
|
@ -95,125 +110,106 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
|
|||
"""Render only JS dependencies available in the media class."""
|
||||
return mark_safe("\n".join(self.media.render_js()))
|
||||
|
||||
def get_declared_slots(
|
||||
self, context: Context, template: Optional[Template] = None
|
||||
) -> List["SlotNode"]:
|
||||
if template is None:
|
||||
template = self.get_template(context)
|
||||
return list(
|
||||
dfs_iter_slots_in_nodelist(template.nodelist, template.name)
|
||||
)
|
||||
|
||||
def get_template(self, context, template_name: Optional[str] = None):
|
||||
if template_name is None:
|
||||
def get_template(self, context) -> Template:
|
||||
template_string = self.get_template_string(context)
|
||||
if template_string is not None:
|
||||
return Template(template_string)
|
||||
else:
|
||||
template_name = self.get_template_name(context)
|
||||
template = get_template(template_name).template
|
||||
return template
|
||||
|
||||
def set_instance_fills(self, fills: Dict[str, "FillNode"]) -> None:
|
||||
self._instance_fills = fills
|
||||
|
||||
def set_outer_context(self, context):
|
||||
self._outer_context = context
|
||||
|
||||
@property
|
||||
def instance_fills(self):
|
||||
return self._instance_fills or {}
|
||||
|
||||
@property
|
||||
def outer_context(self):
|
||||
return self._outer_context or {}
|
||||
|
||||
def get_updated_fill_stacks(self, context):
|
||||
current_fill_stacks: Optional[Dict[str, List[FillNode]]] = context.get(
|
||||
FILLED_SLOTS_CONTEXT_KEY, None
|
||||
)
|
||||
updated_fill_stacks = {}
|
||||
if current_fill_stacks:
|
||||
for name, fill_nodes in current_fill_stacks.items():
|
||||
updated_fill_stacks[name] = list(fill_nodes)
|
||||
for name, fill in self.instance_fills.items():
|
||||
if name in updated_fill_stacks:
|
||||
updated_fill_stacks[name].append(fill)
|
||||
else:
|
||||
updated_fill_stacks[name] = [fill]
|
||||
return updated_fill_stacks
|
||||
|
||||
def validate_fills_and_slots_(
|
||||
self,
|
||||
context,
|
||||
template: Template,
|
||||
fills: Optional[Dict[str, "FillNode"]] = None,
|
||||
) -> None:
|
||||
if fills is None:
|
||||
fills = self.instance_fills
|
||||
all_slots: List["SlotNode"] = self.get_declared_slots(
|
||||
context, template
|
||||
)
|
||||
slots: Dict[str, "SlotNode"] = {}
|
||||
# Each declared slot must have a unique name.
|
||||
for slot in all_slots:
|
||||
slot_name = slot.name
|
||||
if slot_name in slots:
|
||||
raise TemplateSyntaxError(
|
||||
f"Encountered non-unique slot '{slot_name}' in template "
|
||||
f"'{template.name}' of component '{self._component_name}'."
|
||||
)
|
||||
slots[slot_name] = slot
|
||||
# All fill nodes must correspond to a declared slot.
|
||||
unmatchable_fills = fills.keys() - slots.keys()
|
||||
if unmatchable_fills:
|
||||
msg = (
|
||||
f"Component '{self._component_name}' passed fill(s) "
|
||||
f"refering to undefined slot(s). Bad fills: {list(unmatchable_fills)}."
|
||||
)
|
||||
raise TemplateSyntaxError(msg)
|
||||
# Note: Requirement that 'required' slots be filled is enforced
|
||||
# in SlotNode.render().
|
||||
template: Template = get_template(template_name).template
|
||||
return template
|
||||
|
||||
def render(self, context):
|
||||
template_name = self.get_template_name(context)
|
||||
template = self.get_template(context, template_name)
|
||||
self.validate_fills_and_slots_(context, template)
|
||||
updated_fill_stacks = self.get_updated_fill_stacks(context)
|
||||
with context.update({FILLED_SLOTS_CONTEXT_KEY: updated_fill_stacks}):
|
||||
template = self.get_template(context)
|
||||
updated_filled_slots_context: FilledSlotsContext = (
|
||||
self._process_template_and_update_filled_slot_context(
|
||||
context, template
|
||||
)
|
||||
)
|
||||
with context.update(
|
||||
{FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_filled_slots_context}
|
||||
):
|
||||
return template.render(context)
|
||||
|
||||
class Media:
|
||||
css = {}
|
||||
js = []
|
||||
|
||||
|
||||
def dfs_iter_slots_in_nodelist(
|
||||
nodelist: NodeList, template_name: str = None
|
||||
) -> Iterator["SlotNode"]:
|
||||
from django_components.templatetags.component_tags import SlotNode
|
||||
|
||||
nodes: List[Node] = list(nodelist)
|
||||
while nodes:
|
||||
node = nodes.pop()
|
||||
if isinstance(node, SlotNode):
|
||||
yield node
|
||||
for nodelist_name in node.child_nodelists:
|
||||
nodes.extend(reversed(getattr(node, nodelist_name, [])))
|
||||
|
||||
|
||||
# This variable represents the global component registry
|
||||
registry = ComponentRegistry()
|
||||
|
||||
|
||||
def register(name):
|
||||
"""Class decorator to register a component.
|
||||
|
||||
Usage:
|
||||
|
||||
@register("my_component")
|
||||
class MyComponent(component.Component):
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(component):
|
||||
registry.register(name=name, component=component)
|
||||
return component
|
||||
|
||||
return decorator
|
||||
def _process_template_and_update_filled_slot_context(
|
||||
self, context: Context, template: Template
|
||||
) -> FilledSlotsContext:
|
||||
fill_target2content: Dict[Optional[str], FillContent]
|
||||
if isinstance(self.fill_content, NodeList):
|
||||
fill_target2content = {None: (self.fill_content, None)}
|
||||
else:
|
||||
fill_target2content = {
|
||||
name: (nodelist, alias)
|
||||
for name, nodelist, alias in self.fill_content
|
||||
}
|
||||
slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {}
|
||||
default_slot_already_encountered: bool = False
|
||||
for node in template.nodelist.get_nodes_by_type(
|
||||
(SlotNode, IfSlotFilledConditionBranchNode) # type: ignore
|
||||
):
|
||||
if isinstance(node, SlotNode):
|
||||
# Give slot node knowledge of its parent template.
|
||||
node.template = template
|
||||
slot_name = node.name
|
||||
if slot_name in slot_name2fill_content:
|
||||
raise TemplateSyntaxError(
|
||||
f"Slot name '{slot_name}' re-used within the same template. "
|
||||
f"Slot names must be unique."
|
||||
f"To fix, check template '{template.name}' "
|
||||
f"of component '{self.registered_name}'."
|
||||
)
|
||||
content_data: Optional[
|
||||
FillContent
|
||||
] = None # `None` -> unfilled
|
||||
if node.is_default:
|
||||
if default_slot_already_encountered:
|
||||
raise TemplateSyntaxError(
|
||||
"Only one component slot may be marked as 'default'. "
|
||||
f"To fix, check template '{template.name}' "
|
||||
f"of component '{self.registered_name}'."
|
||||
)
|
||||
default_slot_already_encountered = True
|
||||
content_data = fill_target2content.get(None)
|
||||
if not content_data:
|
||||
content_data = fill_target2content.get(node.name)
|
||||
if not content_data and node.is_required:
|
||||
raise TemplateSyntaxError(
|
||||
f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), "
|
||||
f"yet no fill is provided. Check template.'"
|
||||
)
|
||||
slot_name2fill_content[slot_name] = content_data
|
||||
elif isinstance(node, IfSlotFilledConditionBranchNode):
|
||||
node.template = template
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Node of {type(node).__name__} does not require linking."
|
||||
)
|
||||
# Check fills
|
||||
if (
|
||||
None in fill_target2content
|
||||
and not default_slot_already_encountered
|
||||
):
|
||||
raise TemplateSyntaxError(
|
||||
f"Component '{self.registered_name}' passed default fill content "
|
||||
f"(i.e. without explicit 'fill' tag), "
|
||||
f"even though none of its slots is marked as 'default'."
|
||||
)
|
||||
for fill_name in filter(None, fill_target2content.keys()):
|
||||
if fill_name not in slot_name2fill_content:
|
||||
raise TemplateSyntaxError(
|
||||
f"Component '{self.registered_name}' passed fill "
|
||||
f"that refers to undefined slot: {fill_name}"
|
||||
)
|
||||
# Return updated FILLED_SLOTS_CONTEXT map
|
||||
filled_slots_map: Dict[Tuple[SlotName, Template], FillContent] = {
|
||||
(slot_name, template): content_data
|
||||
for slot_name, content_data in slot_name2fill_content.items()
|
||||
if content_data # (is not None)
|
||||
}
|
||||
try:
|
||||
prev_context: FilledSlotsContext = context[
|
||||
FILLED_SLOTS_CONTENT_CONTEXT_KEY
|
||||
]
|
||||
return prev_context.new_child(filled_slots_map)
|
||||
except KeyError:
|
||||
return ChainMap(filled_slots_map)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue