Introduce {% fill %} replacing 'fill' func of 'slot' tag

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
This commit is contained in:
lemontheme 2023-01-11 15:53:18 +01:00 committed by Emil Stenström
parent 714fc9edb0
commit a8dfcce24e
20 changed files with 1090 additions and 307 deletions

118
README.md
View file

@ -323,7 +323,18 @@ This makes it possible to organize your front-end around reusable components. In
# Using slots in templates
Components support something called slots. They work a lot like Django blocks, but only inside components you define. Let's update our calendar component to support more customization, by updating our calendar.html template:
_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.
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.
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.
Let's update our calendar component to support more customization by updating our calendar.html template.
```htmldjango
<div class="calendar-component">
@ -336,17 +347,17 @@ Components support something called slots. They work a lot like Django blocks, b
</div>
```
When using the component, you specify what slots you want to fill and where you want to use the defaults from the template. It looks like this:
When using the component, you specify which slots you want to fill and where you want to use the defaults from the template. It looks like this:
```htmldjango
{% component_block "calendar" date="2020-06-06" %}
{% slot "body" %}Can you believe it's already <span>{{ date }}</span>??{% endslot %}
{% fill "body" %}Can you believe it's already <span>{{ date }}</span>??{% endfill %}
{% endcomponent_block %}
```
Since the header block is unspecified, it's taken from the base template. If you put this in a template, and send in date=2020-06-06, this is what's rendered:
```html
```htmldjango
<div class="calendar-component">
<div class="header">
Calendar header
@ -360,17 +371,17 @@ Since the header block is unspecified, it's taken from the base template. If you
As you can see, component slots lets you write reusable containers, that you fill out when you use a component. This makes for highly reusable components, that can be used in different circumstances.
If you want to include a slot's default content while adding additional content, you can call `slot.super` to insert the base content, which works similarly to `block.super`.
Certain properties of a slot can be accessed from within a 'fill' context. They are provided as attributes on a user-defined alias of the targeted slot. For instance, let's say you're filling a slot called 'body'. To access properties of this slot, alias it using the 'as' keyword to a new name -- or keep the original name. With the new slot alias, you can call `<alias>.default` to insert the default content.
```htmldjango
{% component_block "calendar" date="2020-06-06" %}
{% slot "body" %}{{ slot.super }}. Have a great day!{% endslot %}
{% fill "body" as "body" %}{{ body.default }}. Have a great day!{% endslot %}
{% endcomponent_block %}
```
Produces:
```html
```htmldjango
<div class="calendar-component">
<div class="header">
Calendar header
@ -381,6 +392,99 @@ Produces:
</div>
```
## Advanced
### Conditional slots
_Added in version 0.26._
In certain circumstances, you may want the behavior of slot filling to depend on
whether or not a particular slot is filled.
For example, suppose we have the following component template:
```htmldjango
<div class="frontmatter-component">
<div class="title">
{% slot "title" %}Title{% endslot %}
</div>
<div class="subtitle">
{% slot "subtitle" %}{# Optional subtitle #}{% endslot %}
</div>
</div>
```
By default the slot named 'subtitle' is empty. Yet when the component is used without
explicit fills, the div containing the slot is still rendered, as shown below:
```html
<div class="frontmatter-component">
<div class="title">
Title
</div>
<div class="subtitle">
</div>
</div>
```
This may not be what you want. What if instead the outer 'subtitle' div should only
be included when the inner slot is in fact filled?
The answer is to use the `{% if_filled <name> %}` tag. Together with `{% endif_filled %}`,
these define a block whose contents will be rendered only if the component slot with
the corresponding 'name' is filled.
This is what our example looks like with an 'if_filled' tag.
```htmldjango
<div class="frontmatter-component">
<div class="title">
{% slot "title" %}Title{% endslot %}
</div>
{% if_filled "subtitle" %}
<div class="subtitle">
{% slot "subtitle" %}{# Optional subtitle #}{% endslot %}
</div>
{% endif_filled %}
</div>
```
Just as Django's builtin 'if' tag has 'elif' and 'else' counterparts, so does 'if_filled'
include additional tags for more complex branching. These tags are 'elif_filled' and
'else_filled'. Here's what our example looks like with them.
```htmldjango
<div class="frontmatter-component">
<div class="title">
{% slot "title" %}Title{% endslot %}
</div>
{% if_filled "subtitle" %}
<div class="subtitle">
{% slot "subtitle" %}{# Optional subtitle #}{% endslot %}
</div>
{% elif_filled "title" %}
...
{% else_filled %}
...
{% endif_filled %}
</div>
```
Sometimes you're not interested in whether a slot is filled, but rather that it _isn't_.
To negate the meaning of 'if_filled' in this way, an optional boolean can be passed to
the 'if_filled' and 'elif_filled' tags.
In the example below we use `False` to indicate that the content should be rendered
only if the slot 'subtitle' is _not_ filled.
```htmldjango
{% if_filled subtitle False %}
<div class="subtitle">
{% slot "subtitle" %}{% endslot %}
</div>
{% endif_filled %}
```
# Component context and scope
By default, components can access context variables from the parent template, just like templates that are included with the `{% include %}` tag. Just like with `{% include %}`, if you don't want the component template to have access to the parent context, add `only` to the end of the `{% component %}` (or `{% component_block %}` tag):

View file

@ -1,7 +1,7 @@
import glob
import importlib
import importlib.util
import sys
from importlib import import_module
from pathlib import Path
import django
@ -15,7 +15,7 @@ if django.VERSION < (3, 2):
def autodiscover():
from . import app_settings
from django_components.app_settings import app_settings
if app_settings.AUTODISCOVER:
# Autodetect a components.py file in each app directory
@ -30,7 +30,7 @@ def autodiscover():
import_file(path)
for path in app_settings.LIBRARIES:
import_module(path)
importlib.import_module(path)
def import_file(path):

View file

@ -1,5 +1,3 @@
import sys
from django.conf import settings
@ -19,7 +17,11 @@ 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()
app_settings.__name__ = __name__
sys.modules[__name__] = app_settings

View file

@ -1,13 +1,29 @@
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.base import Node, TokenType
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,
@ -15,10 +31,17 @@ from django_components.component_registry import ( # noqa
NotRegistered,
)
TEMPLATE_CACHE_SIZE = getattr(settings, "COMPONENTS", {}).get(
"TEMPLATE_CACHE_SIZE", 128
)
ACTIVE_SLOT_CONTEXT_KEY = "_DJANGO_COMPONENTS_ACTIVE_SLOTS"
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):
@ -48,18 +71,21 @@ class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
template_name = None
# 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 = component_name
self.instance_template = None
self.slots = {}
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 {}
def get_template_name(self, context=None):
if not self.template_name:
# 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__}"
)
@ -68,94 +94,131 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
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 slots_in_template(template):
return {
node.name: node.nodelist
for node in template.template.nodelist
if Component.is_slot_node(node)
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 is_slot_node(node):
return (
isinstance(node, Node)
and node.token.token_type == TokenType.BLOCK
and node.token.split_contents()[0] == "slot"
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)
@lru_cache(maxsize=TEMPLATE_CACHE_SIZE)
def get_processed_template(self, template_name):
"""Retrieve the requested template and check for unused slots."""
@property
def instance_fills(self):
return self._instance_fills or {}
component_template = get_template(template_name).template
@property
def outer_context(self):
return self._outer_context or {}
# Traverse template nodes and descendants
visited_nodes = set()
nodes_to_visit = list(component_template.nodelist)
slots_seen = set()
while nodes_to_visit:
current_node = nodes_to_visit.pop()
if current_node in visited_nodes:
continue
visited_nodes.add(current_node)
for nodelist_name in current_node.child_nodelists:
nodes_to_visit.extend(getattr(current_node, nodelist_name, []))
if self.is_slot_node(current_node):
slots_seen.add(current_node.name)
# Check and warn for unknown slots
if settings.DEBUG:
filled_slot_names = set(self.slots.keys())
unused_slots = filled_slot_names - slots_seen
if unused_slots:
warnings.warn(
"Component {} was provided with slots that were not used in a template: {}".format(
self._component_name, unused_slots
)
)
return component_template
@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):
if hasattr(self, "context"):
warnings.warn(
f"{self.__class__.__name__}: `context` method is deprecated, use `get_context` instead",
DeprecationWarning,
template = self.get_processed_template(context)
current_fills_stack = context.get(
FILLED_SLOTS_CONTEXT_KEY, defaultdict(list)
)
if hasattr(self, "template"):
warnings.warn(
f"{self.__class__.__name__}: `template` method is deprecated, \
set `template_name` or override `get_template_name` instead",
DeprecationWarning,
)
template_name = self.template(context)
else:
template_name = self.get_template_name(context)
instance_template = self.get_processed_template(template_name)
with context.update({ACTIVE_SLOT_CONTEXT_KEY: self.slots}):
return instance_template.render(context)
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()
@ -163,13 +226,11 @@ registry = ComponentRegistry()
def register(name):
"""Class decorator to register a component.
Usage:
@register("my_component")
class MyComponent(component.Component):
...
"""
def decorator(component):

View file

@ -1,17 +1,30 @@
from collections import defaultdict
from __future__ import annotations
from typing import TYPE_CHECKING, DefaultDict, List, Optional, Tuple
from django import template
from django.conf import settings
from django.template.base import Node, NodeList, TemplateSyntaxError, TokenType
from django.template import Context
from django.template.base import (
Node,
NodeList,
TemplateSyntaxError,
TokenType,
Variable,
VariableDoesNotExist,
)
from django.template.library import parse_bits
from django.utils.safestring import mark_safe
from django_components.component import ACTIVE_SLOT_CONTEXT_KEY, registry
from django_components.component import FILLED_SLOTS_CONTEXT_KEY, registry
from django_components.middleware import (
CSS_DEPENDENCY_PLACEHOLDER,
JS_DEPENDENCY_PLACEHOLDER,
)
if TYPE_CHECKING:
from django_components.component import Component
register = template.Library()
@ -123,113 +136,185 @@ def do_component(parser, token):
parser, bits, "component"
)
return ComponentNode(
component_name,
NameVariable(component_name, tag="component"),
context_args,
context_kwargs,
isolated_context=isolated_context,
)
class UserSlotVar:
"""
Extensible mechanism for offering 'fill' blocks in template access to properties
of parent slot.
How it works: At render time, SlotNode(s) that have been aliased in the fill tag
of the component instance create an instance of UserSlotVar. This instance is made
available to the rendering context on a key matching the slot alias (see
SlotNode.render() for implementation).
"""
def __init__(self, slot: SlotNode, context: Context):
self._slot = slot
self._context = context
@property
def default(self) -> str:
return mark_safe(self._slot.nodelist.render(self._context))
class SlotNode(Node):
def __init__(self, name, nodelist):
self.name, self.nodelist = name, nodelist
self.parent_component = None
self.context = None
self.name = name
self.nodelist = nodelist
self.component_cls = None
self.is_conditional: bool = False
def __repr__(self):
return "<Slot Node: %s. Contents: %r>" % (self.name, self.nodelist)
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}>"
def render(self, context):
# Thread safety: storing the context as a property of the cloned SlotNode without using
# the render_context facility should be thread-safe, since each cloned_node
# is only used for a single render.
cloned_node = SlotNode(self.name, self.nodelist)
cloned_node.parent_component = self.parent_component
cloned_node.context = context
with context.update({"slot": cloned_node}):
return self.get_nodelist(context).render(context)
def get_nodelist(self, context):
if ACTIVE_SLOT_CONTEXT_KEY not in context:
if FILLED_SLOTS_CONTEXT_KEY not in context:
raise TemplateSyntaxError(
f"Attempted to render SlotNode {self.name} outside of a parent Component or "
"without access to context provided by its parent Component. This will not"
"work properly."
f"Attempted to render SlotNode '{self.name}' outside a parent component."
)
overriding_nodelist = context[ACTIVE_SLOT_CONTEXT_KEY].get(
self.name, None
)
return (
overriding_nodelist
if overriding_nodelist is not None
else self.nodelist
)
def super(self):
"""Render default slot content."""
return mark_safe(self.nodelist.render(self.context))
filled_slots: DefaultDict[str, List[FillNode]] = context[
FILLED_SLOTS_CONTEXT_KEY
]
fill_node_stack = filled_slots[self.name]
extra_context = {}
if not fill_node_stack: # if []
nodelist = self.nodelist
else:
fill_node = fill_node_stack.pop()
nodelist = fill_node.nodelist
# context[FILLED_SLOTS_CONTEXT_KEY].pop(self.name)
if fill_node.alias_var is not None:
aliased_slot_var = UserSlotVar(self, context)
resolved_alias_name = fill_node.alias_var.resolve(context)
extra_context[resolved_alias_name] = aliased_slot_var
with context.update(extra_context):
return nodelist.render(context)
@register.tag("slot")
def do_slot(parser, token):
bits = token.split_contents()
if len(bits) != 2:
raise TemplateSyntaxError("'%s' tag takes only one argument" % bits[0])
args = bits[1:]
# e.g. {% slot <name> %}
if len(args) == 1:
slot_name: str = args[0]
else:
raise TemplateSyntaxError(
f"{bits[0]}' tag takes only one argument (the slot name)"
)
if not is_wrapped_in_quotes(slot_name):
raise TemplateSyntaxError(
f"'{bits[0]}' name must be a string 'literal'."
)
slot_name = strip_quotes(slot_name)
raise_if_not_py_identifier(slot_name, bits[0])
slot_name = bits[1].strip('"')
nodelist = parser.parse(parse_until=["endslot"])
parser.delete_first_token()
return SlotNode(slot_name, nodelist)
class ComponentNode(Node):
class InvalidSlot:
def super(self):
class FillNode(Node):
def __init__(
self,
name_var: NameVariable,
nodelist: NodeList,
alias_var: Optional[NameVariable] = None,
):
self.name_var = name_var
self.nodelist = nodelist
self.alias_var: Optional[NameVariable] = alias_var
def __repr__(self):
return f"<Fill Node: {self.name_var}. Contents: {repr(self.nodelist)}>"
def render(self, context):
raise TemplateSyntaxError(
"slot.super may only be called within a {% slot %}/{% endslot %} block."
f"{{% fill {self.name_var} %}} blocks cannot be rendered directly. "
f"You are probably seeing this because you have used one outside "
f"a {{% component_block %}} context."
)
@register.tag("fill")
def do_fill(parser, token):
"""Block tag whose contents 'fill' (are inserted into) an identically named
'slot'-block in the component template referred to by a parent component_block.
It exists to make component nesting easier.
This tag is available only within a {% component_block %}..{% endcomponent_block %} block.
Runtime checks should prohibit other usages.
"""
bits = token.split_contents()
tag = bits[0]
args = bits[1:]
# e.g. {% fill <name> %}
alias_var = None
if len(args) == 1:
tgt_slot_name: str = args[0]
# e.g. {% fill <name> as <alias> %}
elif len(args) == 3:
tgt_slot_name, as_keyword, alias = args
if as_keyword.lower() != "as":
raise TemplateSyntaxError(
f"{tag} tag args do not conform to pattern '<target slot> as <alias>'"
)
raise_if_not_py_identifier(strip_quotes(alias), tag="alias")
alias_var = NameVariable(alias, tag="alias")
else:
raise TemplateSyntaxError(
f"'{tag}' tag takes either 1 or 3 arguments: Received {len(args)}."
)
raise_if_not_py_identifier(strip_quotes(tgt_slot_name), tag=tag)
nodelist = parser.parse(parse_until=["endfill"])
parser.delete_first_token()
return FillNode(NameVariable(tgt_slot_name, tag), nodelist, alias_var)
class ComponentNode(Node):
child_nodelists = ("fill_nodes",)
def __init__(
self,
component_name,
name_var: NameVariable,
context_args,
context_kwargs,
slots=None,
isolated_context=False,
):
self.name_var = name_var
self.context_args = context_args or []
self.context_kwargs = context_kwargs or {}
self.component_name, self.isolated_context = (
component_name,
isolated_context,
)
self.slots = slots
self.fill_nodes: List[FillNode] = []
self.isolated_context = isolated_context
def __repr__(self):
return "<Component Node: %s. Contents: %r>" % (
self.component_name,
self.nodelist,
self.name_var,
getattr(
self, "nodelist", None
), # 'nodelist' attribute only assigned later.
)
def render(self, context):
component_name = template.Variable(self.component_name).resolve(
context
)
component_class = registry.get(component_name)
component = component_class(component_name)
resolved_component_name = self.name_var.resolve(context)
component_cls = registry.get(resolved_component_name)
component: Component = component_cls(resolved_component_name)
# Group slot notes by name and concatenate their nodelists
component.slots = defaultdict(NodeList)
for slot in self.slots or []:
component.slots[slot.name].extend(slot.nodelist)
component.outer_context = context.flatten()
# Resolve FilterExpressions and Variables that were passed as args to the component, then call component's
# context method to get values to insert into the context
# Resolve FilterExpressions and Variables that were passed as args to the
# component, then call component's context method
# to get values to insert into the context
resolved_context_args = [
safe_resolve(arg, context) for arg in self.context_args
]
@ -237,16 +322,24 @@ class ComponentNode(Node):
key: safe_resolve(kwarg, context)
for key, kwarg in self.context_kwargs.items()
}
resolved_fills = {
fill_node.name_var.resolve(context): fill_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_context = component.get_context_data(
*resolved_context_args, **resolved_context_kwargs
)
# Create a fresh context if requested
if self.isolated_context:
context = context.new()
with context.update(component_context):
rendered_component = component.render(context)
if is_dependency_middleware_active():
return (
RENDERED_COMMENT_TEMPLATE.format(
@ -278,21 +371,36 @@ def do_component_block(parser, token):
component_name, context_args, context_kwargs = parse_component_with_args(
parser, bits, "component_block"
)
return ComponentNode(
component_name,
component_node = ComponentNode(
NameVariable(component_name, "component"),
context_args,
context_kwargs,
slots=[
do_slot(parser, slot_token) for slot_token in slot_tokens(parser)
],
isolated_context=isolated_context,
)
seen_fill_name_vars = set()
fill_nodes = component_node.fill_nodes
for token in fill_tokens(parser):
fill_node = do_fill(parser, token)
fill_node.parent_component = component_node
if fill_node.name_var.var in seen_fill_name_vars:
raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: "
f"Detected duplicate fill tag name '{fill_node.name_var}'."
)
seen_fill_name_vars.add(fill_node.name_var.var)
fill_nodes.append(fill_node)
def slot_tokens(parser):
"""Yield each 'slot' token appearing before the next 'endcomponent_block' token.
return component_node
Raises TemplateSyntaxError if there are other content tokens or if there is no endcomponent_block token.
def fill_tokens(parser):
"""Yield each 'fill' token appearing before the next 'endcomponent_block' token.
Raises TemplateSyntaxError if:
- there are other content tokens
- there is no endcomponent_block token.
- a (deprecated) 'slot' token is encountered.
"""
def is_whitespace(token):
@ -313,16 +421,162 @@ def slot_tokens(parser):
raise TemplateSyntaxError("Unclosed component_block tag")
if is_block_tag(token, name="endcomponent_block"):
return
elif is_block_tag(token, name="slot"):
elif is_block_tag(token, name="fill"):
yield token
elif is_block_tag(token, name="slot"):
raise TemplateSyntaxError(
"Use of {% slot %} to pass slot content is deprecated. "
"Use {% fill % } instead."
)
elif (
not is_whitespace(token) and token.token_type != TokenType.COMMENT
):
raise TemplateSyntaxError(
f"Content tokens in component blocks must be inside of slot tags: {token}"
f"Content tokens in component blocks must be placed inside 'fill' tags: {token}"
)
@register.tag(name="if_filled")
def do_if_filled_block(parser, token):
"""
### Usage
Example:
```
{% if_filled <slot> (<bool>) %}
...
{% elif_filled <slot> (<bool>) %}
...
{% else_filled %}
...
{% endif_filled %}
```
Notes:
Optional arg `<bool>` is True by default.
If a False is provided instead, the effect is a negation of the `if_filled` check:
The behavior is analogous to `if not is_filled <slot>`.
This design prevents us having to define a separate `if_unfilled` tag.
"""
bits = token.split_contents()
starting_tag = bits[0]
slot_name_var: Optional[NameVariable]
slot_name_var, is_positive = parse_if_filled_bits(bits)
nodelist = parser.parse(("elif_filled", "else_filled", "endif_filled"))
branches: List[Tuple[Optional[NameVariable], NodeList, Optional[bool]]] = [
(slot_name_var, nodelist, is_positive)
]
token = parser.next_token()
# {% elif_filled <slot> (<is_positive>) %} (repeatable)
while token.contents.startswith("elif_filled"):
bits = token.split_contents()
slot_name_var, is_positive = parse_if_filled_bits(bits)
nodelist: NodeList = parser.parse(
("elif_filled", "else_filled", "endif_filled")
)
branches.append((slot_name_var, nodelist, is_positive))
token = parser.next_token()
# {% else_filled %} (optional)
if token.contents.startswith("else_filled"):
bits = token.split_contents()
_, _ = parse_if_filled_bits(bits)
nodelist = parser.parse(("endif_filled",))
branches.append((None, nodelist, None))
token = parser.next_token()
# {% endif_filled %}
if token.contents != "endif_filled":
raise TemplateSyntaxError(
f"{{% {starting_tag} %}} missing closing {{% endif_filled %}} tag"
f" at line {token.lineno}: '{token.contents}'"
)
return IfSlotFilledNode(branches)
def parse_if_filled_bits(
bits: List[str],
) -> Tuple[Optional[NameVariable], Optional[bool]]:
tag, args = bits[0], bits[1:]
if tag in ("else_filled", "endif_filled"):
if len(args) != 0:
raise TemplateSyntaxError(
f"Tag '{tag}' takes no arguments. "
f"Received '{' '.join(args)}'"
)
else:
return None, None
if len(args) == 1:
slot_name = args[0]
is_positive = True
elif len(args) == 2:
slot_name = args[0]
is_positive = bool_from_string(args[1])
else:
raise TemplateSyntaxError(
f"{bits[0]} tag arguments '{' '.join(args)}' do not match pattern "
f"'<slotname> (<is_positive>)'"
)
raise_if_not_py_identifier(strip_quotes(slot_name), tag=tag)
slot_name_var = NameVariable(slot_name, tag)
return slot_name_var, is_positive
class IfSlotFilledNode(Node):
def __init__(
self,
branches: List[
Tuple[Optional[NameVariable], NodeList, Optional[bool]]
],
):
# [(<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:
for node in nodelist:
yield 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)
def render(self, context):
current_fills = context.get(FILLED_SLOTS_CONTEXT_KEY)
for slot_name_var, nodelist, is_positive in self.branches:
# None indicates {% else_filled %} has been reached.
# This means all other branches have been exhausted.
if slot_name_var is None:
return nodelist.render(context)
# Make polarity switchable.
# i.e. if slot name is NOT filled and is_positive=False,
# then False == False -> True
slot_name = slot_name_var.resolve(context)
if (slot_name in current_fills) == is_positive:
return nodelist.render(context)
else:
continue
return ""
def check_for_isolated_context_keyword(bits):
"""Return True and strip the last word if token ends with 'only' keyword."""
@ -344,14 +598,13 @@ def parse_component_with_args(parser, bits, tag_name):
kwonly=[],
kwonly_defaults=None,
)
assert (
tag_name == tag_args[0].token
), "Internal error: Expected tag_name to be {}, but it was {}".format(
tag_name, tag_args[0].token
if tag_name != tag_args[0].token:
raise RuntimeError(
f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}"
)
if (
len(tag_args) > 1
): # At least one position arg, so take the first as the component name
if len(tag_args) > 1:
# At least one position arg, so take the first as the component name
component_name = tag_args[1].token
context_args = tag_args[2:]
context_kwargs = tag_kwargs
@ -362,8 +615,7 @@ def parse_component_with_args(parser, bits, tag_name):
context_kwargs = tag_kwargs
except IndexError:
raise TemplateSyntaxError(
"Call the '%s' tag with a component name as the first parameter"
% tag_name
f"Call the '{tag_name}' tag with a component name as the first parameter"
)
return component_name, context_args, context_kwargs
@ -387,3 +639,60 @@ def is_dependency_middleware_active():
return getattr(settings, "COMPONENTS", {}).get(
"RENDER_DEPENDENCIES", False
)
def norm_and_validate_name(name: str, tag: str, context: str = None):
"""
Notes:
- Value of `tag` in {"slot", "fill", "alias"}
"""
name = strip_quotes(name)
if not name.isidentifier():
context = f" in '{context}'" if context else ""
raise TemplateSyntaxError(
f"{tag} name '{name}'{context} "
"is not a valid Python identifier."
)
return name
def raise_if_not_py_identifier(name: str, tag: str, content: str = None):
"""
Notes:
- Value of `tag` in {"slot", "fill", "alias", "component"}
"""
if not name.isidentifier():
content = f" in '{{% {content} ...'" if content else ""
raise TemplateSyntaxError(
f"'{tag}' name '{name}'{content} with/without quotes "
"is not a valid Python identifier."
)
def strip_quotes(s: str) -> str:
return s.strip("\"'")
def bool_from_string(s: str):
s = strip_quotes(s.lower())
if s == "true":
return True
elif s == "false":
return False
else:
raise TemplateSyntaxError(f"Expected a bool value. Received: '{s}'")
class NameVariable(Variable):
def __init__(self, var: str, tag: str):
super().__init__(var)
self._tag = tag
def resolve(self, context):
try:
return super().resolve(context)
except VariableDoesNotExist:
raise TemplateSyntaxError(
f"<name> = '{self.var}' in '{{% {self._tag} <name> ...' can't be resolved "
f"against context."
)

View file

@ -18,6 +18,7 @@ exclude = '''
[tool.isort]
profile = "black"
line_length = 79
multi_line_output = 3
include_trailing_comma = "True"
known_first_party = "django_components"

View file

@ -1,3 +1,11 @@
[flake8]
ignore = E302,W503
max-line-length = 119
exclude =
migrations
__pycache__
manage.py
settings.py
env
.env
.tox

View file

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

View file

@ -0,0 +1,11 @@
{% load component_tags %}
<div class="calendar-component">
<h1>
{% slot "header" %}Today's date is <span>{{ date }}</span>{% endslot %}
</h1>
<main>
{% slot "body" %}
You have no events today.
{% endslot %}
</main>
</div>

View file

@ -0,0 +1,13 @@
{% load component_tags %}
<div class="dashboard-component">
{% component_block "calendar" date="2020-06-06" %}
{% fill "header" %} {# fills and slots with same name relate to diff. things. #}
{% slot "header" %}Welcome to your dashboard!{% endslot %}
{% endfill %}
{% fill "body" %}Here are your to-do items for today:{% endfill %}
{% endcomponent_block %}
<ol>
{% for item in items %}
<li>{{ item }}</li>{% endfor %}
</ol>
</div>

View file

@ -1,6 +1,6 @@
{% load component_tags %}
<custom-template>
<header>{% slot header %}Default header{% endslot %}</header>
<main>{% slot main %}Default main{% endslot %}</main>
<footer>{% slot footer %}Default footer{% endslot %}</footer>
<header>{% slot "header" %}Default header{% endslot %}</header>
<main>{% slot "main" %}Default main{% endslot %}</main>
<footer>{% slot "footer" %}Default footer{% endslot %}</footer>
</custom-template>

View file

@ -1,7 +1,7 @@
{% load component_tags %}
<custom-template>
{{ missing_context_variable }}
<header>{% slot header %}Default header{% endslot %}</header>
<main>{% slot main %}Default main{% endslot %}</main>
<footer>{% slot footer %}Default footer{% endslot %}</footer>
<header>{% slot "header" %}Default header{% endslot %}</header>
<main>{% slot "main" %}Default main{% endslot %}</main>
<footer>{% slot "footer" %}Default footer{% endslot %}</footer>
</custom-template>

View file

@ -0,0 +1,8 @@
{# Example from django-components/issues/98 #}
{% load component_tags %}
<div class="frontmatter-component">
<div class="title">{% slot "title" %}Title{% endslot %}</div>
{% if_filled "subtitle" %}
<div class="subtitle">{% slot "subtitle" %}Optional subtitle{% endslot %}</div>
{% endif_filled %}
</div>

View file

@ -0,0 +1,12 @@
{# Example from django-components/issues/98 #}
{% load component_tags %}
<div class="frontmatter-component">
<div class="title">{% slot "title" %}Title{% endslot %}</div>
{% if_filled "subtitle" %}
<div class="subtitle">{% slot "subtitle" %}Optional subtitle{% endslot %}</div>
{% elif_filled "alt_subtitle" %}
<div class="subtitle">{% slot "alt_subtitle" %}Why would you want this?{% endslot %}</div>
{% else_filled %}
<div class="warning">Nothing filled!</div>
{% endif_filled %}
</div>

View file

@ -0,0 +1,4 @@
{% load component_tags %}
<header>
{% slot "header" %} Default content and {{ header.default }}{% endslot %}
</header>

View file

@ -0,0 +1,10 @@
{# Example from django-components/issues/98 #}
{% load component_tags %}
<div class="frontmatter-component">
<div class="title">{% slot "title" %}Title{% endslot %}</div>
{% if_filled "subtitle" False %}
<div class="warning">Subtitle not filled!</div>
{% else_filled %}
<div class="subtitle">{% slot "alt_subtitle" %}Why would you want this?{% endslot %}</div>
{% endif_filled %}
</div>

View file

@ -0,0 +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.
<footer>{% slot "footer" %}Default footer{% endslot %}</footer>

View file

@ -5,13 +5,12 @@ from django.template import Context, Template
# isort: off
from .django_test_setup import * # NOQA
from .testutils import Django30CompatibleSimpleTestCase as SimpleTestCase
# isort: on
from django_components import component
from .testutils import Django30CompatibleSimpleTestCase as SimpleTestCase
class ComponentTest(SimpleTestCase):
def test_empty_component(self):
@ -19,7 +18,7 @@ class ComponentTest(SimpleTestCase):
pass
with self.assertRaises(ImproperlyConfigured):
EmptyComponent("empty_component").get_template_name()
EmptyComponent("empty_component").get_template_name(Context({}))
def test_simple_component(self):
class SimpleComponent(component.Component):
@ -275,13 +274,13 @@ class ComponentIsolationTests(SimpleTestCase):
"""
{% load component_tags %}
{% component_block "test" %}
{% slot "header" %}Override header{% endslot %}
{% fill "header" %}Override header{% endfill %}
{% endcomponent_block %}
{% component_block "test" %}
{% slot "main" %}Override main{% endslot %}
{% fill "main" %}Override main{% endfill %}
{% endcomponent_block %}
{% component_block "test" %}
{% slot "footer" %}Override footer{% endslot %}
{% fill "footer" %}Override footer{% endfill %}
{% endcomponent_block %}
"""
)
@ -309,43 +308,3 @@ class ComponentIsolationTests(SimpleTestCase):
</custom-template>
""",
)
class RecursiveSlotNameTest(SimpleTestCase):
def setUp(self):
@component.register("outer")
class OuterComponent(component.Component):
template_name = "slotted_template.html"
@component.register("inner")
class InnerComponent(component.Component):
template_name = "slotted_template.html"
def test_no_infinite_recursion_when_slot_name_is_reused(self):
template = Template(
"""
{% load component_tags %}
{% component_block "outer" %}
{% slot "header" %}
{% component_block "inner" %}{% endcomponent_block %}
{% endslot %}
{% endcomponent_block %}
"""
)
self.assertHTMLEqual(
template.render(Context({})),
"""
<custom-template>
<header>
<custom-template>
<header>Default header</header>
<main>Default main</main>
<footer>Default footer</footer>
</custom-template>
</header>
<main>Default main</main>
<footer>Default footer</footer>
</custom-template>
""",
)

View file

@ -157,8 +157,8 @@ class ContextTests(SimpleTestCase):
template = Template(
"{% load component_tags %}{% component_dependencies %}"
"{% component_block 'parent_component' %}"
"{% slot 'content' %}{% component name='variable_display' "
"shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endslot %}"
"{% fill 'content' %}{% component name='variable_display' "
"shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endfill %}"
"{% endcomponent_block %}"
)
rendered = template.render(Context())
@ -181,8 +181,8 @@ class ContextTests(SimpleTestCase):
template = Template(
"{% load component_tags %}{% component_dependencies %}"
"{% component_block 'parent_component' %}"
"{% slot 'content' %}{% component name='variable_display' "
"shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endslot %}"
"{% fill 'content' %}{% component name='variable_display' "
"shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endfill %}"
"{% endcomponent_block %}"
)
rendered = template.render(Context())
@ -248,8 +248,8 @@ class ContextTests(SimpleTestCase):
template = Template(
"{% load component_tags %}{% component_dependencies %}"
"{% component_block 'parent_component' %}"
"{% slot 'content' %}{% component name='variable_display' "
"shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endslot %}"
"{% fill 'content' %}{% component name='variable_display' "
"shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endfill %}"
"{% endcomponent_block %}"
)
rendered = template.render(
@ -309,8 +309,8 @@ class ParentArgsTests(SimpleTestCase):
template = Template(
"{% load component_tags %}{% component_dependencies %}"
"{% component_block 'parent_with_args' parent_value='passed_in' %}"
"{% slot 'content' %}{% component name='variable_display' "
"shadowing_variable='value_from_slot' new_variable=inner_parent_value %}{% endslot %}"
"{% fill 'content' %}{% component name='variable_display' "
"shadowing_variable='value_from_slot' new_variable=inner_parent_value %}{% endfill %}"
"{%endcomponent_block %}"
)
rendered = template.render(Context())
@ -373,8 +373,8 @@ class ContextCalledOnceTests(SimpleTestCase):
def test_one_context_call_with_slot(self):
template = Template(
"{% load component_tags %}"
"{% component_block 'incrementer' %}{% slot 'content' %}"
"<p>slot</p>{% endslot %}{% endcomponent_block %}"
"{% component_block 'incrementer' %}{% fill 'content' %}"
"<p>slot</p>{% endfill %}{% endcomponent_block %}"
)
rendered = template.render(Context()).strip()
@ -387,8 +387,8 @@ class ContextCalledOnceTests(SimpleTestCase):
def test_one_context_call_with_slot_and_arg(self):
template = Template(
"{% load component_tags %}"
"{% component_block 'incrementer' value='3' %}{% slot 'content' %}"
"<p>slot</p>{% endslot %}{% endcomponent_block %}"
"{% component_block 'incrementer' value='3' %}{% fill 'content' %}"
"<p>slot</p>{% endfill %}{% endcomponent_block %}"
)
rendered = template.render(Context()).strip()

View file

@ -1,14 +1,19 @@
import re
from textwrap import dedent
from typing import Callable
from django.template import Context, Template, TemplateSyntaxError
import django_components
from django_components import component
# isort: off
from .django_test_setup import * # NOQA
from .testutils import Django30CompatibleSimpleTestCase as SimpleTestCase
# isort: on
import django_components
import django_components.component_registry
from django_components import component
class SimpleComponent(component.Component):
template_name = "simple_template.html"
@ -36,6 +41,10 @@ class BrokenComponent(component.Component):
template_name = "template_with_illegal_slot.html"
class NonUniqueSlotsComponent(component.Component):
template_name = "template_with_nonunique_slots.html"
class SlottedComponentWithMissingVariable(component.Component):
template_name = "slotted_template_with_missing_variable.html"
@ -58,6 +67,16 @@ class ComponentWithProvidedAndDefaultParameters(component.Component):
return {"variable": variable, "default_param": default_param}
class _CalendarComponent(component.Component):
"""Nested in ComponentWithNestedComponent"""
template_name = "slotted_component_nesting_template_pt1_calendar.html"
class _DashboardComponent(component.Component):
template_name = "slotted_component_nesting_template_pt2_dashboard.html"
class ComponentTemplateTagTest(SimpleTestCase):
def setUp(self):
# NOTE: component.registry is global, so need to clear before each test
@ -191,12 +210,12 @@ class ComponentSlottedTemplateTagTest(SimpleTestCase):
"""
{% load component_tags %}
{% component_block "test1" %}
{% slot "header" %}
{% fill "header" %}
Custom header
{% endslot %}
{% slot "main" %}
{% endfill %}
{% fill "main" %}
{% component "test2" variable="variable" %}
{% endslot %}
{% endfill %}
{% endcomponent_block %}
"""
)
@ -223,12 +242,12 @@ class ComponentSlottedTemplateTagTest(SimpleTestCase):
{% load component_tags %}
{% with my_first_variable="test123" %}
{% component_block "test1" variable="test456" %}
{% slot "main" %}
{% fill "main" %}
{{ my_first_variable }} - {{ variable }}
{% endslot %}
{% slot "footer" %}
{% endfill %}
{% fill "footer" %}
{{ my_second_variable }}
{% endslot %}
{% endfill %}
{% endcomponent_block %}
{% endwith %}
"""
@ -293,6 +312,28 @@ class ComponentSlottedTemplateTagTest(SimpleTestCase):
self.assertHTMLEqual(rendered, "<custom-template></custom-template>")
def test_variable_fill_name(self):
component.registry.register(name="test", component=SlottedComponent)
template = Template(
"""
{% load component_tags %}
{% with slotname="header" %}
{% component_block 'test' %}
{% fill slotname %}Hi there!{% endfill %}
{% endcomponent_block %}
{% endwith %}
"""
)
rendered = template.render(Context({}))
expected = """
<custom-template>
<header>Hi there!</header>
<main>Default main</main>
<footer>Default footer</footer>
</custom-template>
"""
self.assertHTMLEqual(rendered, expected)
class SlottedTemplateRegressionTests(SimpleTestCase):
def setUp(self):
@ -328,7 +369,11 @@ class SlottedTemplateRegressionTests(SimpleTestCase):
)
template = Template(
'{% load component_tags %}{% component_block "test" variable="provided value" %}{% endcomponent_block %}'
"""
{% load component_tags %}
{% component_block "test" variable="provided value" %}
{% endcomponent_block %}
"""
)
rendered = template.render(Context({}))
self.assertHTMLEqual(
@ -373,7 +418,7 @@ class MultiComponentTests(SimpleTestCase):
)
def wrap_with_slot_tags(self, s):
return '{% slot "header" %}' + s + "{% endslot %}"
return '{% fill "header" %}' + s + "{% endfill %}"
def test_both_components_render_correctly_with_no_slots(self):
self.register_components()
@ -414,6 +459,8 @@ class MultiComponentTests(SimpleTestCase):
class TemplateInstrumentationTest(SimpleTestCase):
saved_render_method: Callable # Assigned during setup.
@classmethod
def setUpClass(cls):
"""Emulate Django test instrumentation for TestCase (see setup_test_environment)"""
@ -449,7 +496,10 @@ class TemplateInstrumentationTest(SimpleTestCase):
def test_template_shown_as_used(self):
template = Template(
"{% load component_tags %}{% component 'test_component' %}",
"""
{% load component_tags %}
{% component 'test_component' %}
""",
name="root",
)
templates_used = self.templates_used_to_render(template)
@ -457,9 +507,14 @@ class TemplateInstrumentationTest(SimpleTestCase):
def test_nested_component_templates_all_shown_as_used(self):
template = Template(
"{% load component_tags %}{% component_block 'test_component' %}"
"{% slot \"header\" %}{% component 'inner_component' variable='foo' %}{% endslot %}"
"{% endcomponent_block %}",
"""
{% load component_tags %}
{% component_block 'test_component' %}
{% fill "header" %}
{% component 'inner_component' variable='foo' %}
{% endfill %}
{% endcomponent_block %}
""",
name="root",
)
templates_used = self.templates_used_to_render(template)
@ -496,7 +551,7 @@ class NestedSlotTests(SimpleTestCase):
template = Template(
"""
{% load component_tags %}
{% component_block 'test' %}{% slot 'inner' %}Override{% endslot %}{% endcomponent_block %}
{% component_block 'test' %}{% fill 'inner' %}Override{% endfill %}{% endcomponent_block %}
"""
)
rendered = template.render(Context({}))
@ -506,7 +561,7 @@ class NestedSlotTests(SimpleTestCase):
template = Template(
"""
{% load component_tags %}
{% component_block 'test' %}{% slot 'outer' %}<p>Override</p>{% endslot %}{% endcomponent_block %}
{% component_block 'test' %}{% fill 'outer' %}<p>Override</p>{% endfill %}{% endcomponent_block %}
"""
)
rendered = template.render(Context({}))
@ -517,8 +572,8 @@ class NestedSlotTests(SimpleTestCase):
"""
{% load component_tags %}
{% component_block 'test' %}
{% slot 'outer' %}<p>Override</p>{% endslot %}
{% slot 'inner' %}<p>Will not appear</p>{% endslot %}
{% fill 'outer' %}<p>Override</p>{% endfill %}
{% fill 'inner' %}<p>Will not appear</p>{% endfill %}
{% endcomponent_block %}
"""
)
@ -549,8 +604,8 @@ class ConditionalSlotTests(SimpleTestCase):
"""
{% load component_tags %}
{% component_block 'test' %}
{% slot 'a' %}Override A{% endslot %}
{% slot 'b' %}Override B{% endslot %}
{% fill 'a' %}Override A{% endfill %}
{% fill 'b' %}Override B{% endfill %}
{% endcomponent_block %}
"""
)
@ -575,10 +630,10 @@ class ConditionalSlotTests(SimpleTestCase):
"""
{% load component_tags %}
{% component_block 'test' branch='a' %}
{% slot 'b' %}Override B{% endslot %}
{% fill 'b' %}Override B{% endfill %}
{% endcomponent_block %}
{% component_block 'test' branch='b' %}
{% slot 'b' %}Override B{% endslot %}
{% fill 'b' %}Override B{% endfill %}
{% endcomponent_block %}
"""
)
@ -592,12 +647,12 @@ class ConditionalSlotTests(SimpleTestCase):
"""
{% load component_tags %}
{% component_block 'test' branch='a' %}
{% slot 'a' %}Override A{% endslot %}
{% slot 'b' %}Override B{% endslot %}
{% fill 'a' %}Override A{% endfill %}
{% fill 'b' %}Override B{% endfill %}
{% endcomponent_block %}
{% component_block 'test' branch='b' %}
{% slot 'a' %}Override A{% endslot %}
{% slot 'b' %}Override B{% endslot %}
{% fill 'a' %}Override A{% endfill %}
{% fill 'b' %}Override B{% endfill %}
{% endcomponent_block %}
"""
)
@ -624,9 +679,9 @@ class SlotSuperTests(SimpleTestCase):
"""
{% load component_tags %}
{% component_block "test" %}
{% slot "header" %}Before: {{ slot.super }}{% endslot %}
{% slot "main" %}{{ slot.super }}{% endslot %}
{% slot "footer" %}{{ slot.super }}, after{% endslot %}
{% fill "header" as "header" %}Before: {{ header.default }}{% endfill %}
{% fill "main" as "main" %}{{ main.default }}{% endfill %}
{% fill "footer" as "footer" %}{{ footer.default }}, after{% endfill %}
{% endcomponent_block %}
"""
)
@ -648,7 +703,7 @@ class SlotSuperTests(SimpleTestCase):
"""
{% load component_tags %}
{% component_block "test" %}
{% slot "header" %}First: {{ slot.super }}; Second: {{ slot.super }}{% endslot %}
{% fill "header" as "header" %}First: {{ header.default }}; Second: {{ header.default }}{% endfill %}
{% endcomponent_block %}
"""
)
@ -670,13 +725,13 @@ class SlotSuperTests(SimpleTestCase):
"""
{% load component_tags %}
{% component_block "test" %}
{% slot "header" %}
{% fill "header" as "header" %}
{% for i in range %}
{% if forloop.first %}First {{slot.super}}
{% else %}Later {{ slot.super }}
{% if forloop.first %}First {{ header.default }}
{% else %}Later {{ header.default }}
{% endif %}
{%endfor %}
{% endslot %}
{% endfor %}
{% endfill %}
{% endcomponent_block %}
"""
)
@ -700,19 +755,27 @@ class TemplateSyntaxErrorTests(SimpleTestCase):
super().setUpClass()
component.registry.register("test", SlottedComponent)
component.registry.register("broken_component", BrokenComponent)
component.registry.register(
"nonunique_slot_component", NonUniqueSlotsComponent
)
def test_variable_outside_slot_tag_is_error(self):
@classmethod
def tearDownClass(cls) -> None:
super().tearDownClass()
component.registry.clear()
def test_variable_outside_fill_tag_is_error(self):
with self.assertRaises(TemplateSyntaxError):
Template(
"""
{% load component_tags %}
{% component_block "test" %}
{{ slot.super }}
{{ anything }}
{% endcomponent_block %}
"""
)
def test_text_outside_slot_tag_is_error(self):
def test_text_outside_fill_tag_is_error(self):
with self.assertRaises(TemplateSyntaxError):
Template(
"""
@ -723,14 +786,14 @@ class TemplateSyntaxErrorTests(SimpleTestCase):
"""
)
def test_nonslot_block_outside_slot_tag_is_error(self):
def test_nonfill_block_outside_fill_tag_is_error(self):
with self.assertRaises(TemplateSyntaxError):
Template(
"""
{% load component_tags %}
{% component_block "test" %}
{% if True %}
{% slot "header" %}{% endslot %}
{% fill "header" %}{% endfill %}
{% endif %}
{% endcomponent_block %}
"""
@ -742,16 +805,16 @@ class TemplateSyntaxErrorTests(SimpleTestCase):
"""
{% load component_tags %}
{% component_block "test" %}
{% slot "header" %}{% endslot %}
{% fill "header" %}{% endfill %}
"""
)
def test_slot_with_no_parent_is_error(self):
def test_fill_with_no_parent_is_error(self):
with self.assertRaises(TemplateSyntaxError):
Template(
"""
{% load component_tags %}
{% slot "header" %}contents{% endslot %}
{% fill "header" %}contents{% endfill %}
"""
).render(Context({}))
@ -761,9 +824,222 @@ class TemplateSyntaxErrorTests(SimpleTestCase):
"""
{% load component_tags %}
{% component_block "broken_component" %}
{% slot "header" %}Custom header{% endslot %}
{% slot "main" %}Custom main{% endslot %}
{% slot "footer" %}Custom footer{% endslot %}
{% fill "header" %}Custom header {% endfill %}
{% fill "main" %}Custom main{% endfill %}
{% fill "footer" %}Custom footer{% endfill %}
{% endcomponent_block %}
"""
).render(Context({}))
def test_non_unique_fill_names_is_error(self):
with self.assertRaises(TemplateSyntaxError):
Template(
"""
{% load component_tags %}
{% component_block "broken_component" %}
{% fill "header" %}Custom header {% endfill %}
{% fill "header" %}Other header{% endfill %}
{% endcomponent_block %}
"""
).render(Context({}))
def test_non_unique_slot_names_is_error(self):
with self.assertRaises(TemplateSyntaxError):
Template(
"""
{% load component_tags %}
{% component_block "nonunique_slot_component" %}
{% endcomponent_block %}
"""
).render(Context({}))
class ComponentNestingTests(SimpleTestCase):
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
component.registry.register("dashboard", _DashboardComponent)
component.registry.register("calendar", _CalendarComponent)
@classmethod
def tearDownClass(cls) -> None:
super().tearDownClass()
component.registry.clear()
def test_component_nesting_component_without_fill(self):
template = Template(
"""
{% load component_tags %}
{% component "dashboard" %}
"""
)
rendered = template.render(Context({"items": [1, 2, 3]}))
expected = """
<div class="dashboard-component">
<div class="calendar-component">
<h1>
Welcome to your dashboard!
</h1>
<main>
Here are your to-do items for today:
</main>
</div>
<ol>
<li>1</li>
<li>2</li>
<li>3</li>
</ol>
</div>
"""
self.assertHTMLEqual(rendered, expected)
def test_component_nesting_component_with_fill_and_super(self):
template = Template(
"""
{% load component_tags %}
{% component_block "dashboard" %}
{% fill "header" as "h" %} Hello! {{ h.default }} {% endfill %}
{% endcomponent_block %}
"""
)
import sys
sys.setrecursionlimit(100)
rendered = template.render(Context({"items": [1, 2]}))
expected = """
<div class="dashboard-component">
<div class="calendar-component">
<h1>
Hello! Welcome to your dashboard!
</h1>
<main>
Here are your to-do items for today:
</main>
</div>
<ol>
<li>1</li>
<li>2</li>
</ol>
</div>
"""
self.assertHTMLEqual(rendered, expected)
class ConditionalIfFilledSlotsTests(SimpleTestCase):
class ComponentWithConditionalSlots(component.Component):
template_name = "template_with_conditional_slots.html"
class ComponentWithComplexConditionalSlots(component.Component):
template_name = "template_with_if_elif_else_conditional_slots.html"
class ComponentWithNegatedConditionalSlot(component.Component):
template_name = "template_with_negated_conditional_slots.html"
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
component.registry.register(
"conditional_slots", cls.ComponentWithConditionalSlots
)
component.registry.register(
"complex_conditional_slots",
cls.ComponentWithComplexConditionalSlots,
)
component.registry.register(
"negated_conditional_slot", cls.ComponentWithNegatedConditionalSlot
)
@classmethod
def tearDownClass(cls) -> None:
super().tearDownClass()
component.registry.clear()
def test_simple_component_with_conditional_slot(self):
template = """
{% load component_tags %}
{% component "conditional_slots" %}
"""
expected = """
<div class="frontmatter-component">
<div class="title">
Title
</div>
</div>
"""
rendered = Template(template).render(Context({}))
self.assertHTMLEqual(rendered, expected)
def test_component_block_with_filled_conditional_slot(self):
template = """
{% load component_tags %}
{% component_block "conditional_slots" %}
{% fill "subtitle" %} My subtitle {% endfill %}
{% endcomponent_block %}
"""
expected = """
<div class="frontmatter-component">
<div class="title">
Title
</div>
<div class="subtitle">
My subtitle
</div>
</div>
"""
rendered = Template(template).render(Context({}))
self.assertHTMLEqual(rendered, expected)
def test_elif_of_complex_conditional_slots(self):
template = """
{% load component_tags %}
{% component_block "complex_conditional_slots" %}
{% fill "alt_subtitle" %} A different subtitle {% endfill %}
{% endcomponent_block %}
"""
expected = """
<div class="frontmatter-component">
<div class="title">
Title
</div>
<div class="subtitle">
A different subtitle
</div>
</div>
"""
rendered = Template(template).render(Context({}))
self.assertHTMLEqual(rendered, expected)
def test_else_of_complex_conditional_slots(self):
template = """
{% load component_tags %}
{% component_block "complex_conditional_slots" %}
{% endcomponent_block %}
"""
expected = """
<div class="frontmatter-component">
<div class="title">
Title
</div>
<div class="warning">Nothing filled!</div>
</div>
"""
rendered = Template(template).render(Context({}))
self.assertHTMLEqual(rendered, expected)
def test_component_block_with_negated_conditional_slot(self):
template = """
{% load component_tags %}
{% component_block "negated_conditional_slot" %}
{# Whoops! Forgot to fill a slot! #}
{% endcomponent_block %}
"""
expected = """
<div class="frontmatter-component">
<div class="title">
Title
</div>
<div class="warning">Subtitle not filled!</div>
</div>
"""
rendered = Template(template).render(Context({}))
self.assertHTMLEqual(rendered, expected)