mirror of
https://github.com/django-components/django-components.git
synced 2025-07-14 04:14:58 +00:00
219 lines
7 KiB
Python
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
|