django-components/django_components/component.py
2023-04-06 16:08:17 +02:00

219 lines
7 KiB
Python

import copy
from typing import (
TYPE_CHECKING,
ClassVar,
Dict,
Iterator,
List,
Optional,
TypeVar,
)
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.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
AlreadyRegistered,
ComponentRegistry,
NotRegistered,
)
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):
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]
def __init__(self, component_name):
self._component_name: str = component_name
self._instance_fills: Optional[List["FillNode"]] = None
self._outer_context: Optional[dict] = None
def get_context_data(self, *args, **kwargs):
return {}
# Can be overridden for dynamic templates
def get_template_name(self, context):
if not hasattr(self, "template_name") or not self.template_name:
raise ImproperlyConfigured(
f"Template name is not set for Component {self.__class__.__name__}"
)
return self.template_name
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_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:
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 = context.get(FILLED_SLOTS_CONTEXT_KEY, None)
updated_fill_stacks = (
copy.deepcopy(current_fill_stacks)
if current_fill_stacks is not None
else {}
)
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().
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}):
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