Add required kwd to slot tag and add test

Move required slot check to SlotNode.render(); clean up needed

Remove unused code; drop caching

Update docs

Incorporate PR feedback
This commit is contained in:
lemontheme 2023-03-13 10:22:47 +01:00 committed by Emil Stenström
parent a8dfcce24e
commit 898d148382
9 changed files with 143 additions and 138 deletions

View file

@ -323,7 +323,12 @@ This makes it possible to organize your front-end around reusable components. In
# Using slots in templates
_New in version 0.26_ (__breaking change__): Defining slots and passing content to them used to be achieved by a single block tag `{% slot %}`. To make component nesting easier these functions have been split across two separate tags. Now, `{% slot %}` serves only to declare/define/open new slots inside the component template. The function of passing in content to a slot has been moved to a newly introduced `{% fill %}` tag.
_New in version 0.26_:
- The `slot` tag now serves only to declare new slots inside the component template.
- To override the content of a declared slot, use the newly introduced `fill` tag instead.
- Whereas unfilled slots used to raise a warning, filling a slot is now optional by default.
- To indicate that a slot must be filled, the new keyword `required` should be added at the end of the `slot` tag.
Components support something called 'slots'.
When a component is used inside another template, slots allow the parent template to override specific parts of the child component by passing in different content.
@ -331,8 +336,8 @@ This mechanism makes components more reusable and composable.
In the example below we introduce two block tags that work hand in hand to make this work. These are...
- `{% slot <name> %}`/`{% endslot %}`: Declare new slot on component template.
- `{% fill <name> %}`/`{% endfill %}`: Used inside component block. The content of this block is injected into the slot with the same name.
- `{% slot <name> %}`/`{% endslot %}`: Declares a new slot in the component template.
- `{% fill <name> %}`/`{% endfill %}`: (Used inside a component block.) Fills a declared slot with the specified content.
Let's update our calendar component to support more customization by updating our calendar.html template.

View file

@ -17,11 +17,5 @@ class AppSettings:
def TEMPLATE_CACHE_SIZE(self):
return self.settings.setdefault("template_cache_size", 128)
@property
def STRICT_SLOTS(self):
"""If True, component slots that are declared must be explicitly filled; else
a TemplateSyntaxError is raised."""
return self.settings.setdefault("strict_slots", False)
app_settings = AppSettings()

View file

@ -1,29 +1,23 @@
from __future__ import annotations
import warnings
from collections import defaultdict
from contextlib import contextmanager
from functools import lru_cache
import copy
from typing import (
TYPE_CHECKING,
ClassVar,
Dict,
Iterator,
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 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
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,
@ -104,61 +98,26 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
"""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_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_processed_template(self, context):
def get_template(self, context, template_name: Optional[str] = None):
if template_name is None:
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
)
template = get_template(template_name).template
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)
def set_instance_fills(self, fills: Dict[str, FillNode]) -> None:
self._instance_fills = fills
@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)
def set_outer_context(self, context):
self._outer_context = context
@property
def instance_fills(self):
@ -168,28 +127,56 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
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)
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():
current_fills_stack[name].append(fill)
with context.update({FILLED_SLOTS_CONTEXT_KEY: current_fills_stack}):
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:
@ -197,23 +184,15 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
js = []
def iter_slots_in_nodelist(nodelist: NodeList, template_name: str = None):
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)
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, [])))

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING, DefaultDict, List, Optional, Tuple
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
from django import template
from django.conf import settings
@ -164,11 +164,13 @@ class UserSlotVar:
class SlotNode(Node):
def __init__(self, name, nodelist):
def __init__(
self, name, nodelist, template_name: str = "", required=False
):
self.name = name
self.nodelist = nodelist
self.component_cls = None
self.is_conditional: bool = False
self.template_name = template_name
self.is_required = required
def __repr__(self):
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}>"
@ -178,13 +180,19 @@ class SlotNode(Node):
raise TemplateSyntaxError(
f"Attempted to render SlotNode '{self.name}' outside a parent component."
)
filled_slots: DefaultDict[str, List[FillNode]] = context[
filled_slots: Dict[str, List[FillNode]] = context[
FILLED_SLOTS_CONTEXT_KEY
]
fill_node_stack = filled_slots[self.name]
fill_node_stack = filled_slots.get(self.name, None)
extra_context = {}
if not fill_node_stack: # if []
if not fill_node_stack: # if None or []
nodelist = self.nodelist
# Raise if slot is 'required'
if self.is_required:
raise TemplateSyntaxError(
f"Slot '{self.name}' is marked as 'required' (i.e. non-optional), "
f"yet no fill is provided. Check template '{self.template_name}'"
)
else:
fill_node = fill_node_stack.pop()
nodelist = fill_node.nodelist
@ -204,6 +212,16 @@ def do_slot(parser, token):
# e.g. {% slot <name> %}
if len(args) == 1:
slot_name: str = args[0]
required = False
elif len(args) == 2:
slot_name: str = args[0]
required_keyword = args[1]
if required_keyword != "required":
raise TemplateSyntaxError(
f"'{bits[0]}' only accepts 'required' keyword as optional second argument"
)
else:
required = True
else:
raise TemplateSyntaxError(
f"{bits[0]}' tag takes only one argument (the slot name)"
@ -220,7 +238,8 @@ def do_slot(parser, token):
nodelist = parser.parse(parse_until=["endslot"])
parser.delete_first_token()
return SlotNode(slot_name, nodelist)
template_name = parser.origin.template_name
return SlotNode(slot_name, nodelist, template_name, required)
class FillNode(Node):
@ -328,10 +347,9 @@ class ComponentNode(Node):
for fill_node in self.fill_nodes
}
# Create a fresh isolated context if requested w 'only' keyword.
with component.assign(
fills=resolved_fills, outer_context=context.flatten()
):
component.set_instance_fills(resolved_fills)
component.set_outer_context(context)
component_context = component.get_context_data(
*resolved_context_args, **resolved_context_kwargs
)
@ -432,7 +450,9 @@ def fill_tokens(parser):
not is_whitespace(token) and token.token_type != TokenType.COMMENT
):
raise TemplateSyntaxError(
f"Content tokens in component blocks must be placed inside 'fill' tags: {token}"
"Component block EITHER contains illegal tokens tag that are not "
"{{% fill ... %}} tags OR the proper closing tag -- "
"{{% endcomponent_block %}} -- is missing."
)
@ -536,7 +556,6 @@ class IfSlotFilledNode(Node):
):
# [(<slot name var | None (= condition)>, nodelist, <is_positive>)]
self.branches = branches
self.visit_and_mark_slots_as_conditional_()
def __iter__(self):
for _, nodelist, _ in self.branches:
@ -546,15 +565,6 @@ class IfSlotFilledNode(Node):
def __repr__(self):
return f"<{self.__class__.__name__}>"
def visit_and_mark_slots_as_conditional_(self):
stack = list(self.nodelist)
while stack:
node = stack.pop()
if isinstance(node, SlotNode):
node.is_conditional = True
for nodelist_name in node.child_nodelists:
stack.extend(getattr(node, nodelist_name, ()))
@property
def nodelist(self):
return NodeList(self)

View file

@ -10,12 +10,11 @@ if not settings.configured:
"DIRS": ["tests/templates/"],
}
],
COMPONENTS={"template_cache_size": 128, "strict_slots": False},
COMPONENTS={"template_cache_size": 128},
MIDDLEWARE=[
"django_components.middleware.ComponentDependencyMiddleware"
],
DATABASES={},
# DEBUG=True
)
django.setup()

View file

@ -8,6 +8,7 @@
{% endcomponent_block %}
<ol>
{% for item in items %}
<li>{{ item }}</li>{% endfor %}
<li>{{ item }}</li>
{% endfor %}
</ol>
</div>

View file

@ -0,0 +1,5 @@
{% load component_tags %}
<div class="header-box">
<h1>{% slot "title" required %}{% endslot %}</h1>
<h2>{% slot "subtitle" %}{% endslot %}</h2>
</div>

View file

@ -1,4 +1,4 @@
{% load component_tags %}
<header>{% slot "header" %}Default header{% endslot %}</header>
<main>{% slot "header" %}Default main header{% endslot %}</main> {# <- whoops! slot name 'header' used twice.
<main>{% slot "header" %}Default main header{% endslot %}</main> {# <- whoops! slot name 'header' used twice. #}
<footer>{% slot "footer" %}Default footer{% endslot %}</footer>

View file

@ -334,6 +334,21 @@ class ComponentSlottedTemplateTagTest(SimpleTestCase):
"""
self.assertHTMLEqual(rendered, expected)
def test_missing_required_slot_raises_error(self):
class Component(component.Component):
template_name = "slotted_template_with_required_slot.html"
component.registry.register("test", Component)
template = Template(
"""
{% load component_tags %}
{% component_block 'test' %}
{% endcomponent_block %}
"""
)
with self.assertRaises(TemplateSyntaxError):
template.render(Context({}))
class SlottedTemplateRegressionTests(SimpleTestCase):
def setUp(self):
@ -902,9 +917,6 @@ class ComponentNestingTests(SimpleTestCase):
{% endcomponent_block %}
"""
)
import sys
sys.setrecursionlimit(100)
rendered = template.render(Context({"items": [1, 2]}))
expected = """
<div class="dashboard-component">