mirror of
https://github.com/django-components/django-components.git
synced 2025-08-03 13:58:16 +00:00
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:
parent
714fc9edb0
commit
a8dfcce24e
20 changed files with 1090 additions and 307 deletions
118
README.md
118
README.md
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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."
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1,3 +1,11 @@
|
|||
[flake8]
|
||||
ignore = E302,W503
|
||||
max-line-length = 119
|
||||
exclude =
|
||||
migrations
|
||||
__pycache__
|
||||
manage.py
|
||||
settings.py
|
||||
env
|
||||
.env
|
||||
.tox
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
8
tests/templates/template_with_conditional_slots.html
Normal file
8
tests/templates/template_with_conditional_slots.html
Normal 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>
|
|
@ -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>
|
|
@ -0,0 +1,4 @@
|
|||
{% load component_tags %}
|
||||
<header>
|
||||
{% slot "header" %} Default content and {{ header.default }}{% endslot %}
|
||||
</header>
|
10
tests/templates/template_with_negated_conditional_slots.html
Normal file
10
tests/templates/template_with_negated_conditional_slots.html
Normal 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>
|
4
tests/templates/template_with_nonunique_slots.html
Normal file
4
tests/templates/template_with_nonunique_slots.html
Normal 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>
|
|
@ -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>
|
||||
""",
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue