mirror of
https://github.com/django-components/django-components.git
synced 2025-07-18 05:45:00 +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
215 lines
8.6 KiB
Python
215 lines
8.6 KiB
Python
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 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
|
|
|
|
# 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,
|
|
)
|
|
|
|
|
|
class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
|
|
def __new__(mcs, name, bases, attrs):
|
|
if "Media" in attrs:
|
|
media = attrs["Media"]
|
|
|
|
# Allow: class Media: css = "style.css"
|
|
if hasattr(media, "css") and isinstance(media.css, str):
|
|
media.css = [media.css]
|
|
|
|
# Allow: class Media: css = ["style.css"]
|
|
if hasattr(media, "css") and isinstance(media.css, list):
|
|
media.css = {"all": media.css}
|
|
|
|
# Allow: class Media: css = {"all": "style.css"}
|
|
if hasattr(media, "css") and isinstance(media.css, dict):
|
|
for media_type, path_list in media.css.items():
|
|
if isinstance(path_list, str):
|
|
media.css[media_type] = [path_list]
|
|
|
|
# Allow: class Media: js = "script.js"
|
|
if hasattr(media, "js") and isinstance(media.js, str):
|
|
media.js = [media.js]
|
|
|
|
return super().__new__(mcs, name, bases, attrs)
|
|
|
|
|
|
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
|
|
|
|
class Media:
|
|
css = {}
|
|
js = []
|
|
|
|
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) -> str:
|
|
try:
|
|
name = self.template_name
|
|
except AttributeError:
|
|
raise ImproperlyConfigured(
|
|
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
|
|
|
|
def get_template_string(self, context) -> str:
|
|
...
|
|
|
|
def render_dependencies(self):
|
|
"""Helper function to access media.render()"""
|
|
return self.media.render()
|
|
|
|
def render_css_dependencies(self):
|
|
"""Render only CSS dependencies available in the media class."""
|
|
return mark_safe("\n".join(self.media.render_css()))
|
|
|
|
def render_js_dependencies(self):
|
|
"""Render only JS dependencies available in the media class."""
|
|
return mark_safe("\n".join(self.media.render_js()))
|
|
|
|
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: Template = get_template(template_name).template
|
|
return template
|
|
|
|
def render(self, context):
|
|
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)
|
|
|
|
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)
|