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 # 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 ```htmldjango
<div class="calendar-component"> <div class="calendar-component">
@ -336,17 +347,17 @@ Components support something called slots. They work a lot like Django blocks, b
</div> </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 ```htmldjango
{% component_block "calendar" date="2020-06-06" %} {% 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 %} {% 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: 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="calendar-component">
<div class="header"> <div class="header">
Calendar 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. 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 ```htmldjango
{% component_block "calendar" date="2020-06-06" %} {% 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 %} {% endcomponent_block %}
``` ```
Produces: Produces:
```html ```htmldjango
<div class="calendar-component"> <div class="calendar-component">
<div class="header"> <div class="header">
Calendar header Calendar header
@ -381,6 +392,99 @@ Produces:
</div> </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 # 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): 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 glob
import importlib import importlib
import importlib.util
import sys import sys
from importlib import import_module
from pathlib import Path from pathlib import Path
import django import django
@ -15,7 +15,7 @@ if django.VERSION < (3, 2):
def autodiscover(): def autodiscover():
from . import app_settings from django_components.app_settings import app_settings
if app_settings.AUTODISCOVER: if app_settings.AUTODISCOVER:
# Autodetect a components.py file in each app directory # Autodetect a components.py file in each app directory
@ -30,7 +30,7 @@ def autodiscover():
import_file(path) import_file(path)
for path in app_settings.LIBRARIES: for path in app_settings.LIBRARIES:
import_module(path) importlib.import_module(path)
def import_file(path): def import_file(path):

View file

@ -1,5 +1,3 @@
import sys
from django.conf import settings from django.conf import settings
@ -19,7 +17,11 @@ class AppSettings:
def TEMPLATE_CACHE_SIZE(self): def TEMPLATE_CACHE_SIZE(self):
return self.settings.setdefault("template_cache_size", 128) 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 = AppSettings()
app_settings.__name__ = __name__
sys.modules[__name__] = app_settings

View file

@ -1,13 +1,29 @@
from __future__ import annotations
import warnings import warnings
from collections import defaultdict
from contextlib import contextmanager
from functools import lru_cache from functools import lru_cache
from typing import (
TYPE_CHECKING,
ClassVar,
Dict,
List,
Optional,
Tuple,
TypeVar,
)
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import MediaDefiningClass 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.template.loader import get_template
from django.utils.safestring import mark_safe 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 # Allow "component.AlreadyRegistered" instead of having to import these everywhere
from django_components.component_registry import ( # noqa from django_components.component_registry import ( # noqa
AlreadyRegistered, AlreadyRegistered,
@ -15,10 +31,17 @@ from django_components.component_registry import ( # noqa
NotRegistered, NotRegistered,
) )
TEMPLATE_CACHE_SIZE = getattr(settings, "COMPONENTS", {}).get( if TYPE_CHECKING:
"TEMPLATE_CACHE_SIZE", 128 from django_components.templatetags.component_tags import (
FillNode,
SlotNode,
) )
ACTIVE_SLOT_CONTEXT_KEY = "_DJANGO_COMPONENTS_ACTIVE_SLOTS"
T = TypeVar("T")
FILLED_SLOTS_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass): class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
@ -48,18 +71,21 @@ class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
class Component(metaclass=SimplifiedInterfaceMediaDefiningClass): 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): def __init__(self, component_name):
self._component_name = component_name self._component_name: str = component_name
self.instance_template = None self._instance_fills: Optional[List[FillNode]] = None
self.slots = {} self._outer_context: Optional[dict] = None
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
return {} return {}
def get_template_name(self, context=None): # Can be overridden for dynamic templates
if not self.template_name: def get_template_name(self, context):
if not hasattr(self, "template_name") or not self.template_name:
raise ImproperlyConfigured( raise ImproperlyConfigured(
f"Template name is not set for Component {self.__class__.__name__}" f"Template name is not set for Component {self.__class__.__name__}"
) )
@ -68,94 +94,131 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
def render_dependencies(self): def render_dependencies(self):
"""Helper function to access media.render()""" """Helper function to access media.render()"""
return self.media.render() return self.media.render()
def render_css_dependencies(self): def render_css_dependencies(self):
"""Render only CSS dependencies available in the media class.""" """Render only CSS dependencies available in the media class."""
return mark_safe("\n".join(self.media.render_css())) return mark_safe("\n".join(self.media.render_css()))
def render_js_dependencies(self): def render_js_dependencies(self):
"""Render only JS dependencies available in the media class.""" """Render only JS dependencies available in the media class."""
return mark_safe("\n".join(self.media.render_js())) 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 @staticmethod
def slots_in_template(template): def _raise_if_declared_slots_are_unfilled(
return { slots: Dict[str, SlotNode], fills: Dict[str, FillNode], comp_name: str
node.name: node.nodelist ):
for node in template.template.nodelist # 'unconditional_slots' are slots that were encountered within an 'if_filled'
if Component.is_slot_node(node) # 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 @staticmethod
def is_slot_node(node): def _raise_if_fills_do_not_match_slots(
return ( slots: Dict[str, SlotNode], fills: Dict[str, FillNode], comp_name: str
isinstance(node, Node) ):
and node.token.token_type == TokenType.BLOCK unmatchable_fills = fills.keys() - slots.keys()
and node.token.split_contents()[0] == "slot" 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) @property
def get_processed_template(self, template_name): def instance_fills(self):
"""Retrieve the requested template and check for unused slots.""" 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 @contextmanager
visited_nodes = set() def assign(
nodes_to_visit = list(component_template.nodelist) self: T,
slots_seen = set() fills: Optional[Dict[str, FillNode]] = None,
while nodes_to_visit: outer_context: Optional[dict] = None,
current_node = nodes_to_visit.pop() ) -> T:
if current_node in visited_nodes: if fills is not None:
continue self._instance_fills = fills
visited_nodes.add(current_node) if outer_context is not None:
for nodelist_name in current_node.child_nodelists: self._outer_context = outer_context
nodes_to_visit.extend(getattr(current_node, nodelist_name, [])) yield self
if self.is_slot_node(current_node): self._instance_fills = None
slots_seen.add(current_node.name) self._outer_context = None
# 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
def render(self, context): def render(self, context):
if hasattr(self, "context"): template = self.get_processed_template(context)
warnings.warn( current_fills_stack = context.get(
f"{self.__class__.__name__}: `context` method is deprecated, use `get_context` instead", FILLED_SLOTS_CONTEXT_KEY, defaultdict(list)
DeprecationWarning,
) )
for name, fill in self.instance_fills.items():
if hasattr(self, "template"): current_fills_stack[name].append(fill)
warnings.warn( with context.update({FILLED_SLOTS_CONTEXT_KEY: current_fills_stack}):
f"{self.__class__.__name__}: `template` method is deprecated, \ return template.render(context)
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)
class Media: class Media:
css = {} css = {}
js = [] 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 # This variable represents the global component registry
registry = ComponentRegistry() registry = ComponentRegistry()
@ -163,13 +226,11 @@ registry = ComponentRegistry()
def register(name): def register(name):
"""Class decorator to register a component. """Class decorator to register a component.
Usage: Usage:
@register("my_component") @register("my_component")
class MyComponent(component.Component): class MyComponent(component.Component):
... ...
""" """
def decorator(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 import template
from django.conf import settings 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.template.library import parse_bits
from django.utils.safestring import mark_safe 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 ( from django_components.middleware import (
CSS_DEPENDENCY_PLACEHOLDER, CSS_DEPENDENCY_PLACEHOLDER,
JS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER,
) )
if TYPE_CHECKING:
from django_components.component import Component
register = template.Library() register = template.Library()
@ -123,113 +136,185 @@ def do_component(parser, token):
parser, bits, "component" parser, bits, "component"
) )
return ComponentNode( return ComponentNode(
component_name, NameVariable(component_name, tag="component"),
context_args, context_args,
context_kwargs, context_kwargs,
isolated_context=isolated_context, 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): class SlotNode(Node):
def __init__(self, name, nodelist): def __init__(self, name, nodelist):
self.name, self.nodelist = name, nodelist self.name = name
self.parent_component = None self.nodelist = nodelist
self.context = None self.component_cls = None
self.is_conditional: bool = False
def __repr__(self): 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): def render(self, context):
# Thread safety: storing the context as a property of the cloned SlotNode without using if FILLED_SLOTS_CONTEXT_KEY not in context:
# 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:
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Attempted to render SlotNode {self.name} outside of a parent Component or " f"Attempted to render SlotNode '{self.name}' outside a parent component."
"without access to context provided by its parent Component. This will not"
"work properly."
) )
filled_slots: DefaultDict[str, List[FillNode]] = context[
overriding_nodelist = context[ACTIVE_SLOT_CONTEXT_KEY].get( FILLED_SLOTS_CONTEXT_KEY
self.name, None ]
) fill_node_stack = filled_slots[self.name]
return ( extra_context = {}
overriding_nodelist if not fill_node_stack: # if []
if overriding_nodelist is not None nodelist = self.nodelist
else self.nodelist else:
) fill_node = fill_node_stack.pop()
nodelist = fill_node.nodelist
def super(self): # context[FILLED_SLOTS_CONTEXT_KEY].pop(self.name)
"""Render default slot content.""" if fill_node.alias_var is not None:
return mark_safe(self.nodelist.render(self.context)) 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") @register.tag("slot")
def do_slot(parser, token): def do_slot(parser, token):
bits = token.split_contents() bits = token.split_contents()
if len(bits) != 2: args = bits[1:]
raise TemplateSyntaxError("'%s' tag takes only one argument" % bits[0]) # 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"]) nodelist = parser.parse(parse_until=["endslot"])
parser.delete_first_token() parser.delete_first_token()
return SlotNode(slot_name, nodelist) return SlotNode(slot_name, nodelist)
class ComponentNode(Node): class FillNode(Node):
class InvalidSlot: def __init__(
def super(self): 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( 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__( def __init__(
self, self,
component_name, name_var: NameVariable,
context_args, context_args,
context_kwargs, context_kwargs,
slots=None,
isolated_context=False, isolated_context=False,
): ):
self.name_var = name_var
self.context_args = context_args or [] self.context_args = context_args or []
self.context_kwargs = context_kwargs or {} self.context_kwargs = context_kwargs or {}
self.component_name, self.isolated_context = ( self.fill_nodes: List[FillNode] = []
component_name, self.isolated_context = isolated_context
isolated_context,
)
self.slots = slots
def __repr__(self): def __repr__(self):
return "<Component Node: %s. Contents: %r>" % ( return "<Component Node: %s. Contents: %r>" % (
self.component_name, self.name_var,
self.nodelist, getattr(
self, "nodelist", None
), # 'nodelist' attribute only assigned later.
) )
def render(self, context): def render(self, context):
component_name = template.Variable(self.component_name).resolve( resolved_component_name = self.name_var.resolve(context)
context component_cls = registry.get(resolved_component_name)
) component: Component = component_cls(resolved_component_name)
component_class = registry.get(component_name)
component = component_class(component_name)
# Group slot notes by name and concatenate their nodelists # Resolve FilterExpressions and Variables that were passed as args to the
component.slots = defaultdict(NodeList) # component, then call component's context method
for slot in self.slots or []: # to get values to insert into the context
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
resolved_context_args = [ resolved_context_args = [
safe_resolve(arg, context) for arg in self.context_args safe_resolve(arg, context) for arg in self.context_args
] ]
@ -237,16 +322,24 @@ class ComponentNode(Node):
key: safe_resolve(kwarg, context) key: safe_resolve(kwarg, context)
for key, kwarg in self.context_kwargs.items() 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( component_context = component.get_context_data(
*resolved_context_args, **resolved_context_kwargs *resolved_context_args, **resolved_context_kwargs
) )
# Create a fresh context if requested
if self.isolated_context: if self.isolated_context:
context = context.new() context = context.new()
with context.update(component_context): with context.update(component_context):
rendered_component = component.render(context) rendered_component = component.render(context)
if is_dependency_middleware_active(): if is_dependency_middleware_active():
return ( return (
RENDERED_COMMENT_TEMPLATE.format( RENDERED_COMMENT_TEMPLATE.format(
@ -278,21 +371,36 @@ def do_component_block(parser, token):
component_name, context_args, context_kwargs = parse_component_with_args( component_name, context_args, context_kwargs = parse_component_with_args(
parser, bits, "component_block" parser, bits, "component_block"
) )
return ComponentNode( component_node = ComponentNode(
component_name, NameVariable(component_name, "component"),
context_args, context_args,
context_kwargs, context_kwargs,
slots=[
do_slot(parser, slot_token) for slot_token in slot_tokens(parser)
],
isolated_context=isolated_context, 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): return component_node
"""Yield each 'slot' token appearing before the next 'endcomponent_block' token.
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): def is_whitespace(token):
@ -313,16 +421,162 @@ def slot_tokens(parser):
raise TemplateSyntaxError("Unclosed component_block tag") raise TemplateSyntaxError("Unclosed component_block tag")
if is_block_tag(token, name="endcomponent_block"): if is_block_tag(token, name="endcomponent_block"):
return return
elif is_block_tag(token, name="slot"): elif is_block_tag(token, name="fill"):
yield token yield token
elif is_block_tag(token, name="slot"):
raise TemplateSyntaxError(
"Use of {% slot %} to pass slot content is deprecated. "
"Use {% fill % } instead."
)
elif ( elif (
not is_whitespace(token) and token.token_type != TokenType.COMMENT not is_whitespace(token) and token.token_type != TokenType.COMMENT
): ):
raise TemplateSyntaxError( 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): def check_for_isolated_context_keyword(bits):
"""Return True and strip the last word if token ends with 'only' keyword.""" """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=[],
kwonly_defaults=None, kwonly_defaults=None,
) )
assert (
tag_name == tag_args[0].token if tag_name != tag_args[0].token:
), "Internal error: Expected tag_name to be {}, but it was {}".format( raise RuntimeError(
tag_name, tag_args[0].token f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}"
) )
if ( if len(tag_args) > 1:
len(tag_args) > 1 # At least one position arg, so take the first as the component name
): # At least one position arg, so take the first as the component name
component_name = tag_args[1].token component_name = tag_args[1].token
context_args = tag_args[2:] context_args = tag_args[2:]
context_kwargs = tag_kwargs context_kwargs = tag_kwargs
@ -362,8 +615,7 @@ def parse_component_with_args(parser, bits, tag_name):
context_kwargs = tag_kwargs context_kwargs = tag_kwargs
except IndexError: except IndexError:
raise TemplateSyntaxError( raise TemplateSyntaxError(
"Call the '%s' tag with a component name as the first parameter" f"Call the '{tag_name}' tag with a component name as the first parameter"
% tag_name
) )
return component_name, context_args, context_kwargs return component_name, context_args, context_kwargs
@ -387,3 +639,60 @@ def is_dependency_middleware_active():
return getattr(settings, "COMPONENTS", {}).get( return getattr(settings, "COMPONENTS", {}).get(
"RENDER_DEPENDENCIES", False "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] [tool.isort]
profile = "black" profile = "black"
line_length = 79
multi_line_output = 3 multi_line_output = 3
include_trailing_comma = "True" include_trailing_comma = "True"
known_first_party = "django_components" known_first_party = "django_components"

View file

@ -1,3 +1,11 @@
[flake8] [flake8]
ignore = E302,W503 ignore = E302,W503
max-line-length = 119 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/"], "DIRS": ["tests/templates/"],
} }
], ],
COMPONENTS={"TEMPLATE_CACHE_SIZE": 128}, COMPONENTS={"template_cache_size": 128, "strict_slots": False},
MIDDLEWARE=[ MIDDLEWARE=[
"django_components.middleware.ComponentDependencyMiddleware" "django_components.middleware.ComponentDependencyMiddleware"
], ],
DATABASES={}, DATABASES={},
# DEBUG=True
) )
django.setup() 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 %} {% load component_tags %}
<custom-template> <custom-template>
<header>{% slot header %}Default header{% endslot %}</header> <header>{% slot "header" %}Default header{% endslot %}</header>
<main>{% slot main %}Default main{% endslot %}</main> <main>{% slot "main" %}Default main{% endslot %}</main>
<footer>{% slot footer %}Default footer{% endslot %}</footer> <footer>{% slot "footer" %}Default footer{% endslot %}</footer>
</custom-template> </custom-template>

View file

@ -1,7 +1,7 @@
{% load component_tags %} {% load component_tags %}
<custom-template> <custom-template>
{{ missing_context_variable }} {{ missing_context_variable }}
<header>{% slot header %}Default header{% endslot %}</header> <header>{% slot "header" %}Default header{% endslot %}</header>
<main>{% slot main %}Default main{% endslot %}</main> <main>{% slot "main" %}Default main{% endslot %}</main>
<footer>{% slot footer %}Default footer{% endslot %}</footer> <footer>{% slot "footer" %}Default footer{% endslot %}</footer>
</custom-template> </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 # isort: off
from .django_test_setup import * # NOQA from .django_test_setup import * # NOQA
from .testutils import Django30CompatibleSimpleTestCase as SimpleTestCase
# isort: on # isort: on
from django_components import component from django_components import component
from .testutils import Django30CompatibleSimpleTestCase as SimpleTestCase
class ComponentTest(SimpleTestCase): class ComponentTest(SimpleTestCase):
def test_empty_component(self): def test_empty_component(self):
@ -19,7 +18,7 @@ class ComponentTest(SimpleTestCase):
pass pass
with self.assertRaises(ImproperlyConfigured): with self.assertRaises(ImproperlyConfigured):
EmptyComponent("empty_component").get_template_name() EmptyComponent("empty_component").get_template_name(Context({}))
def test_simple_component(self): def test_simple_component(self):
class SimpleComponent(component.Component): class SimpleComponent(component.Component):
@ -275,13 +274,13 @@ class ComponentIsolationTests(SimpleTestCase):
""" """
{% load component_tags %} {% load component_tags %}
{% component_block "test" %} {% component_block "test" %}
{% slot "header" %}Override header{% endslot %} {% fill "header" %}Override header{% endfill %}
{% endcomponent_block %} {% endcomponent_block %}
{% component_block "test" %} {% component_block "test" %}
{% slot "main" %}Override main{% endslot %} {% fill "main" %}Override main{% endfill %}
{% endcomponent_block %} {% endcomponent_block %}
{% component_block "test" %} {% component_block "test" %}
{% slot "footer" %}Override footer{% endslot %} {% fill "footer" %}Override footer{% endfill %}
{% endcomponent_block %} {% endcomponent_block %}
""" """
) )
@ -309,43 +308,3 @@ class ComponentIsolationTests(SimpleTestCase):
</custom-template> </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( template = Template(
"{% load component_tags %}{% component_dependencies %}" "{% load component_tags %}{% component_dependencies %}"
"{% component_block 'parent_component' %}" "{% component_block 'parent_component' %}"
"{% slot 'content' %}{% component name='variable_display' " "{% fill 'content' %}{% component name='variable_display' "
"shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endslot %}" "shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endfill %}"
"{% endcomponent_block %}" "{% endcomponent_block %}"
) )
rendered = template.render(Context()) rendered = template.render(Context())
@ -181,8 +181,8 @@ class ContextTests(SimpleTestCase):
template = Template( template = Template(
"{% load component_tags %}{% component_dependencies %}" "{% load component_tags %}{% component_dependencies %}"
"{% component_block 'parent_component' %}" "{% component_block 'parent_component' %}"
"{% slot 'content' %}{% component name='variable_display' " "{% fill 'content' %}{% component name='variable_display' "
"shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endslot %}" "shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endfill %}"
"{% endcomponent_block %}" "{% endcomponent_block %}"
) )
rendered = template.render(Context()) rendered = template.render(Context())
@ -248,8 +248,8 @@ class ContextTests(SimpleTestCase):
template = Template( template = Template(
"{% load component_tags %}{% component_dependencies %}" "{% load component_tags %}{% component_dependencies %}"
"{% component_block 'parent_component' %}" "{% component_block 'parent_component' %}"
"{% slot 'content' %}{% component name='variable_display' " "{% fill 'content' %}{% component name='variable_display' "
"shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endslot %}" "shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endfill %}"
"{% endcomponent_block %}" "{% endcomponent_block %}"
) )
rendered = template.render( rendered = template.render(
@ -309,8 +309,8 @@ class ParentArgsTests(SimpleTestCase):
template = Template( template = Template(
"{% load component_tags %}{% component_dependencies %}" "{% load component_tags %}{% component_dependencies %}"
"{% component_block 'parent_with_args' parent_value='passed_in' %}" "{% component_block 'parent_with_args' parent_value='passed_in' %}"
"{% slot 'content' %}{% component name='variable_display' " "{% fill 'content' %}{% component name='variable_display' "
"shadowing_variable='value_from_slot' new_variable=inner_parent_value %}{% endslot %}" "shadowing_variable='value_from_slot' new_variable=inner_parent_value %}{% endfill %}"
"{%endcomponent_block %}" "{%endcomponent_block %}"
) )
rendered = template.render(Context()) rendered = template.render(Context())
@ -373,8 +373,8 @@ class ContextCalledOnceTests(SimpleTestCase):
def test_one_context_call_with_slot(self): def test_one_context_call_with_slot(self):
template = Template( template = Template(
"{% load component_tags %}" "{% load component_tags %}"
"{% component_block 'incrementer' %}{% slot 'content' %}" "{% component_block 'incrementer' %}{% fill 'content' %}"
"<p>slot</p>{% endslot %}{% endcomponent_block %}" "<p>slot</p>{% endfill %}{% endcomponent_block %}"
) )
rendered = template.render(Context()).strip() rendered = template.render(Context()).strip()
@ -387,8 +387,8 @@ class ContextCalledOnceTests(SimpleTestCase):
def test_one_context_call_with_slot_and_arg(self): def test_one_context_call_with_slot_and_arg(self):
template = Template( template = Template(
"{% load component_tags %}" "{% load component_tags %}"
"{% component_block 'incrementer' value='3' %}{% slot 'content' %}" "{% component_block 'incrementer' value='3' %}{% fill 'content' %}"
"<p>slot</p>{% endslot %}{% endcomponent_block %}" "<p>slot</p>{% endfill %}{% endcomponent_block %}"
) )
rendered = template.render(Context()).strip() rendered = template.render(Context()).strip()

View file

@ -1,14 +1,19 @@
import re import re
from textwrap import dedent from textwrap import dedent
from typing import Callable
from django.template import Context, Template, TemplateSyntaxError from django.template import Context, Template, TemplateSyntaxError
import django_components # isort: off
from django_components import component
from .django_test_setup import * # NOQA from .django_test_setup import * # NOQA
from .testutils import Django30CompatibleSimpleTestCase as SimpleTestCase 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): class SimpleComponent(component.Component):
template_name = "simple_template.html" template_name = "simple_template.html"
@ -36,6 +41,10 @@ class BrokenComponent(component.Component):
template_name = "template_with_illegal_slot.html" template_name = "template_with_illegal_slot.html"
class NonUniqueSlotsComponent(component.Component):
template_name = "template_with_nonunique_slots.html"
class SlottedComponentWithMissingVariable(component.Component): class SlottedComponentWithMissingVariable(component.Component):
template_name = "slotted_template_with_missing_variable.html" template_name = "slotted_template_with_missing_variable.html"
@ -58,6 +67,16 @@ class ComponentWithProvidedAndDefaultParameters(component.Component):
return {"variable": variable, "default_param": default_param} 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): class ComponentTemplateTagTest(SimpleTestCase):
def setUp(self): def setUp(self):
# NOTE: component.registry is global, so need to clear before each test # NOTE: component.registry is global, so need to clear before each test
@ -191,12 +210,12 @@ class ComponentSlottedTemplateTagTest(SimpleTestCase):
""" """
{% load component_tags %} {% load component_tags %}
{% component_block "test1" %} {% component_block "test1" %}
{% slot "header" %} {% fill "header" %}
Custom header Custom header
{% endslot %} {% endfill %}
{% slot "main" %} {% fill "main" %}
{% component "test2" variable="variable" %} {% component "test2" variable="variable" %}
{% endslot %} {% endfill %}
{% endcomponent_block %} {% endcomponent_block %}
""" """
) )
@ -223,12 +242,12 @@ class ComponentSlottedTemplateTagTest(SimpleTestCase):
{% load component_tags %} {% load component_tags %}
{% with my_first_variable="test123" %} {% with my_first_variable="test123" %}
{% component_block "test1" variable="test456" %} {% component_block "test1" variable="test456" %}
{% slot "main" %} {% fill "main" %}
{{ my_first_variable }} - {{ variable }} {{ my_first_variable }} - {{ variable }}
{% endslot %} {% endfill %}
{% slot "footer" %} {% fill "footer" %}
{{ my_second_variable }} {{ my_second_variable }}
{% endslot %} {% endfill %}
{% endcomponent_block %} {% endcomponent_block %}
{% endwith %} {% endwith %}
""" """
@ -293,6 +312,28 @@ class ComponentSlottedTemplateTagTest(SimpleTestCase):
self.assertHTMLEqual(rendered, "<custom-template></custom-template>") 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): class SlottedTemplateRegressionTests(SimpleTestCase):
def setUp(self): def setUp(self):
@ -328,7 +369,11 @@ class SlottedTemplateRegressionTests(SimpleTestCase):
) )
template = Template( 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({})) rendered = template.render(Context({}))
self.assertHTMLEqual( self.assertHTMLEqual(
@ -373,7 +418,7 @@ class MultiComponentTests(SimpleTestCase):
) )
def wrap_with_slot_tags(self, s): 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): def test_both_components_render_correctly_with_no_slots(self):
self.register_components() self.register_components()
@ -414,6 +459,8 @@ class MultiComponentTests(SimpleTestCase):
class TemplateInstrumentationTest(SimpleTestCase): class TemplateInstrumentationTest(SimpleTestCase):
saved_render_method: Callable # Assigned during setup.
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
"""Emulate Django test instrumentation for TestCase (see setup_test_environment)""" """Emulate Django test instrumentation for TestCase (see setup_test_environment)"""
@ -449,7 +496,10 @@ class TemplateInstrumentationTest(SimpleTestCase):
def test_template_shown_as_used(self): def test_template_shown_as_used(self):
template = Template( template = Template(
"{% load component_tags %}{% component 'test_component' %}", """
{% load component_tags %}
{% component 'test_component' %}
""",
name="root", name="root",
) )
templates_used = self.templates_used_to_render(template) 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): def test_nested_component_templates_all_shown_as_used(self):
template = Template( template = Template(
"{% load component_tags %}{% component_block 'test_component' %}" """
"{% slot \"header\" %}{% component 'inner_component' variable='foo' %}{% endslot %}" {% load component_tags %}
"{% endcomponent_block %}", {% component_block 'test_component' %}
{% fill "header" %}
{% component 'inner_component' variable='foo' %}
{% endfill %}
{% endcomponent_block %}
""",
name="root", name="root",
) )
templates_used = self.templates_used_to_render(template) templates_used = self.templates_used_to_render(template)
@ -496,7 +551,7 @@ class NestedSlotTests(SimpleTestCase):
template = Template( template = Template(
""" """
{% load component_tags %} {% 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({})) rendered = template.render(Context({}))
@ -506,7 +561,7 @@ class NestedSlotTests(SimpleTestCase):
template = Template( template = Template(
""" """
{% load component_tags %} {% 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({})) rendered = template.render(Context({}))
@ -517,8 +572,8 @@ class NestedSlotTests(SimpleTestCase):
""" """
{% load component_tags %} {% load component_tags %}
{% component_block 'test' %} {% component_block 'test' %}
{% slot 'outer' %}<p>Override</p>{% endslot %} {% fill 'outer' %}<p>Override</p>{% endfill %}
{% slot 'inner' %}<p>Will not appear</p>{% endslot %} {% fill 'inner' %}<p>Will not appear</p>{% endfill %}
{% endcomponent_block %} {% endcomponent_block %}
""" """
) )
@ -549,8 +604,8 @@ class ConditionalSlotTests(SimpleTestCase):
""" """
{% load component_tags %} {% load component_tags %}
{% component_block 'test' %} {% component_block 'test' %}
{% slot 'a' %}Override A{% endslot %} {% fill 'a' %}Override A{% endfill %}
{% slot 'b' %}Override B{% endslot %} {% fill 'b' %}Override B{% endfill %}
{% endcomponent_block %} {% endcomponent_block %}
""" """
) )
@ -575,10 +630,10 @@ class ConditionalSlotTests(SimpleTestCase):
""" """
{% load component_tags %} {% load component_tags %}
{% component_block 'test' branch='a' %} {% component_block 'test' branch='a' %}
{% slot 'b' %}Override B{% endslot %} {% fill 'b' %}Override B{% endfill %}
{% endcomponent_block %} {% endcomponent_block %}
{% component_block 'test' branch='b' %} {% component_block 'test' branch='b' %}
{% slot 'b' %}Override B{% endslot %} {% fill 'b' %}Override B{% endfill %}
{% endcomponent_block %} {% endcomponent_block %}
""" """
) )
@ -592,12 +647,12 @@ class ConditionalSlotTests(SimpleTestCase):
""" """
{% load component_tags %} {% load component_tags %}
{% component_block 'test' branch='a' %} {% component_block 'test' branch='a' %}
{% slot 'a' %}Override A{% endslot %} {% fill 'a' %}Override A{% endfill %}
{% slot 'b' %}Override B{% endslot %} {% fill 'b' %}Override B{% endfill %}
{% endcomponent_block %} {% endcomponent_block %}
{% component_block 'test' branch='b' %} {% component_block 'test' branch='b' %}
{% slot 'a' %}Override A{% endslot %} {% fill 'a' %}Override A{% endfill %}
{% slot 'b' %}Override B{% endslot %} {% fill 'b' %}Override B{% endfill %}
{% endcomponent_block %} {% endcomponent_block %}
""" """
) )
@ -624,9 +679,9 @@ class SlotSuperTests(SimpleTestCase):
""" """
{% load component_tags %} {% load component_tags %}
{% component_block "test" %} {% component_block "test" %}
{% slot "header" %}Before: {{ slot.super }}{% endslot %} {% fill "header" as "header" %}Before: {{ header.default }}{% endfill %}
{% slot "main" %}{{ slot.super }}{% endslot %} {% fill "main" as "main" %}{{ main.default }}{% endfill %}
{% slot "footer" %}{{ slot.super }}, after{% endslot %} {% fill "footer" as "footer" %}{{ footer.default }}, after{% endfill %}
{% endcomponent_block %} {% endcomponent_block %}
""" """
) )
@ -648,7 +703,7 @@ class SlotSuperTests(SimpleTestCase):
""" """
{% load component_tags %} {% load component_tags %}
{% component_block "test" %} {% 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 %} {% endcomponent_block %}
""" """
) )
@ -670,13 +725,13 @@ class SlotSuperTests(SimpleTestCase):
""" """
{% load component_tags %} {% load component_tags %}
{% component_block "test" %} {% component_block "test" %}
{% slot "header" %} {% fill "header" as "header" %}
{% for i in range %} {% for i in range %}
{% if forloop.first %}First {{slot.super}} {% if forloop.first %}First {{ header.default }}
{% else %}Later {{ slot.super }} {% else %}Later {{ header.default }}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endslot %} {% endfill %}
{% endcomponent_block %} {% endcomponent_block %}
""" """
) )
@ -700,19 +755,27 @@ class TemplateSyntaxErrorTests(SimpleTestCase):
super().setUpClass() super().setUpClass()
component.registry.register("test", SlottedComponent) component.registry.register("test", SlottedComponent)
component.registry.register("broken_component", BrokenComponent) 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): with self.assertRaises(TemplateSyntaxError):
Template( Template(
""" """
{% load component_tags %} {% load component_tags %}
{% component_block "test" %} {% component_block "test" %}
{{ slot.super }} {{ anything }}
{% endcomponent_block %} {% endcomponent_block %}
""" """
) )
def test_text_outside_slot_tag_is_error(self): def test_text_outside_fill_tag_is_error(self):
with self.assertRaises(TemplateSyntaxError): with self.assertRaises(TemplateSyntaxError):
Template( 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): with self.assertRaises(TemplateSyntaxError):
Template( Template(
""" """
{% load component_tags %} {% load component_tags %}
{% component_block "test" %} {% component_block "test" %}
{% if True %} {% if True %}
{% slot "header" %}{% endslot %} {% fill "header" %}{% endfill %}
{% endif %} {% endif %}
{% endcomponent_block %} {% endcomponent_block %}
""" """
@ -742,16 +805,16 @@ class TemplateSyntaxErrorTests(SimpleTestCase):
""" """
{% load component_tags %} {% load component_tags %}
{% component_block "test" %} {% 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): with self.assertRaises(TemplateSyntaxError):
Template( Template(
""" """
{% load component_tags %} {% load component_tags %}
{% slot "header" %}contents{% endslot %} {% fill "header" %}contents{% endfill %}
""" """
).render(Context({})) ).render(Context({}))
@ -761,9 +824,222 @@ class TemplateSyntaxErrorTests(SimpleTestCase):
""" """
{% load component_tags %} {% load component_tags %}
{% component_block "broken_component" %} {% component_block "broken_component" %}
{% slot "header" %}Custom header{% endslot %} {% fill "header" %}Custom header {% endfill %}
{% slot "main" %}Custom main{% endslot %} {% fill "main" %}Custom main{% endfill %}
{% slot "footer" %}Custom footer{% endslot %} {% fill "footer" %}Custom footer{% endfill %}
{% endcomponent_block %} {% endcomponent_block %}
""" """
).render(Context({})) ).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)