mirror of
https://github.com/django-components/django-components.git
synced 2025-07-14 20:24:59 +00:00

Partial implementation fill-tags plus update tests Implement {% fill %} tags. Next: update tests. Bring back support for {%slot%} blocks for bckwrd-compat and implement ambig. resolution policy Update tests to use fill blocks. Add extra checks that raise errors Add new tests for fill-slot nesting Update README. Editing still required remove unused var ctxt after flake8 complaint fix flake8 warning about slotless f-string [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Add new slot aliases in fill context. Clean up rendering logic in Component. Update docs. fix flake8, isort, black errors Refactor duplicated name validation Add if_filled tag + elif_filled...else_filled...endif_filled for cond. slots Fix mistake in do_if_filled() docstring Upload templates for tests! D'oh Incorporate PR feedback Drop Literal type hint; Use isort off-on instead of skip in tests Treat all fill,slot,if_filled,component names as variables Reset sampleproject components Add test for variable filled name Update examples in docs
240 lines
7.7 KiB
Python
240 lines
7.7 KiB
Python
from __future__ import annotations
|
|
|
|
import warnings
|
|
from collections import defaultdict
|
|
from contextlib import contextmanager
|
|
from functools import lru_cache
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
ClassVar,
|
|
Dict,
|
|
List,
|
|
Optional,
|
|
Tuple,
|
|
TypeVar,
|
|
)
|
|
|
|
from django.conf import settings
|
|
from django.core.exceptions import ImproperlyConfigured
|
|
from django.forms.widgets import MediaDefiningClass
|
|
from django.template import TemplateSyntaxError
|
|
from django.template.base import Node, NodeList, Template
|
|
from django.template.loader import get_template
|
|
from django.utils.safestring import mark_safe
|
|
|
|
from django_components.app_settings import app_settings
|
|
|
|
# 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()))
|
|
|
|
@classmethod
|
|
@lru_cache(maxsize=app_settings.TEMPLATE_CACHE_SIZE)
|
|
def fetch_and_analyze_template(
|
|
cls, template_name: str
|
|
) -> Tuple[Template, Dict[str, SlotNode]]:
|
|
template: Template = get_template(template_name).template
|
|
slots = {}
|
|
for slot in iter_slots_in_nodelist(template.nodelist, template.name):
|
|
slot.component_cls = cls
|
|
slots[slot.name] = slot
|
|
return template, slots
|
|
|
|
def get_processed_template(self, context):
|
|
template_name = self.get_template_name(context)
|
|
# Note: return of method below is cached.
|
|
template, slots = self.fetch_and_analyze_template(template_name)
|
|
self._raise_if_fills_do_not_match_slots(
|
|
slots, self.instance_fills, self._component_name
|
|
)
|
|
self._raise_if_declared_slots_are_unfilled(
|
|
slots, self.instance_fills, self._component_name
|
|
)
|
|
return template
|
|
|
|
@staticmethod
|
|
def _raise_if_declared_slots_are_unfilled(
|
|
slots: Dict[str, SlotNode], fills: Dict[str, FillNode], comp_name: str
|
|
):
|
|
# 'unconditional_slots' are slots that were encountered within an 'if_filled'
|
|
# context. They are exempt from filling checks.
|
|
unconditional_slots = {
|
|
slot.name for slot in slots.values() if not slot.is_conditional
|
|
}
|
|
unused_slots = unconditional_slots - fills.keys()
|
|
if unused_slots:
|
|
msg = (
|
|
f"Component '{comp_name}' declares slots that "
|
|
f"are not filled: '{unused_slots}'"
|
|
)
|
|
if app_settings.STRICT_SLOTS:
|
|
raise TemplateSyntaxError(msg)
|
|
elif settings.DEBUG:
|
|
warnings.warn(msg)
|
|
|
|
@staticmethod
|
|
def _raise_if_fills_do_not_match_slots(
|
|
slots: Dict[str, SlotNode], fills: Dict[str, FillNode], comp_name: str
|
|
):
|
|
unmatchable_fills = fills.keys() - slots.keys()
|
|
if unmatchable_fills:
|
|
msg = (
|
|
f"Component '{comp_name}' passed fill(s) "
|
|
f"refering to undefined slot(s). Bad fills: {list(unmatchable_fills)}."
|
|
)
|
|
raise TemplateSyntaxError(msg)
|
|
|
|
@property
|
|
def instance_fills(self):
|
|
return self._instance_fills or {}
|
|
|
|
@property
|
|
def outer_context(self):
|
|
return self._outer_context or {}
|
|
|
|
@contextmanager
|
|
def assign(
|
|
self: T,
|
|
fills: Optional[Dict[str, FillNode]] = None,
|
|
outer_context: Optional[dict] = None,
|
|
) -> T:
|
|
if fills is not None:
|
|
self._instance_fills = fills
|
|
if outer_context is not None:
|
|
self._outer_context = outer_context
|
|
yield self
|
|
self._instance_fills = None
|
|
self._outer_context = None
|
|
|
|
def render(self, context):
|
|
template = self.get_processed_template(context)
|
|
current_fills_stack = context.get(
|
|
FILLED_SLOTS_CONTEXT_KEY, defaultdict(list)
|
|
)
|
|
for name, fill in self.instance_fills.items():
|
|
current_fills_stack[name].append(fill)
|
|
with context.update({FILLED_SLOTS_CONTEXT_KEY: current_fills_stack}):
|
|
return template.render(context)
|
|
|
|
class Media:
|
|
css = {}
|
|
js = []
|
|
|
|
|
|
def iter_slots_in_nodelist(nodelist: NodeList, template_name: str = None):
|
|
from django_components.templatetags.component_tags import SlotNode
|
|
|
|
nodes: List[Node] = list(nodelist)
|
|
slot_names = set()
|
|
while nodes:
|
|
node = nodes.pop()
|
|
if isinstance(node, SlotNode):
|
|
slot_name = node.name
|
|
if slot_name in slot_names:
|
|
context = (
|
|
f" in template '{template_name}'" if template_name else ""
|
|
)
|
|
raise TemplateSyntaxError(
|
|
f"Encountered non-unique slot '{slot_name}'{context}"
|
|
)
|
|
slot_names.add(slot_name)
|
|
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
|