Implement #231 – default slot filling (#269)

* Add 'default' slot option + implicit fills; tests; docs
* Differentiate between standard fillnodes and implicitfillnodes on type lvl
* Reworking slot-fill rendering logic. Simplifying component interfact. Add new get_string_template method
* First working implementation of chainmap instead of stacks for slot resolution
* Stop passing FillNode to Component initalizer -> better decoupling
* Treat fill name and alias and component name as filterexpression, dropping namedvariable
* Name arg of if_filled tags and slots must be string literal
This commit is contained in:
adriaan 2023-05-18 14:58:46 +02:00 committed by GitHub
parent 349e9fe65f
commit 2d86f042da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 843 additions and 422 deletions

125
README.md
View file

@ -18,9 +18,11 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
Read on to learn about the details!
# Release notes
## Release notes
*Version 0.27* includes an additional installable app *django_components.safer_staticfiles*. It provides the same behavior as *django.contrib.staticfiles* but with extra security guarantees (more info below in Security Notes).
*Version 0.28* introduces 'implicit' slot filling and the `default` option for `slot` tags.
*Version 0.27* adds a second installable app: *django_components.safer_staticfiles*. It provides the same behavior as *django.contrib.staticfiles* but with extra security guarantees (more info below in Security Notes).
*Version 0.26* changes the syntax for `{% slot %}` tags. From now on, we separate defining a slot (`{% slot %}`) from filling a slot with content (`{% fill %}`). This means you will likely need to change a lot of slot tags to fill. We understand this is annoying, but it's the only way we can get support for nested slots that fill in other slots, which is a very nice feature to have access to. Hoping that this will feel worth it!
@ -28,11 +30,11 @@ Read on to learn about the details!
*Version 0.17* renames `Component.context` and `Component.template` to `get_context_data` and `get_template_name`. The old methods still work, but emit a deprecation warning. This change was done to sync naming with Django's class based views, and make using django-components more familiar to Django users. `Component.context` and `Component.template` will be removed when version 1.0 is released.
# Security notes 🚨
## Security notes 🚨
*You are advised to read this section before using django-components in production.*
## Static files
### Static files
Components can be organized however you prefer.
That said, our prefered way is to keep the files of a component close together by bundling them in the same directory.
@ -58,7 +60,7 @@ INSTALLED_APPS = [
If you are on an older version of django-components, your alternatives are a) passing `--ignore <pattern>` options to the _collecstatic_ CLI command, or b) defining a subclass of StaticFilesConfig.
Both routes are described in the official [docs of the _staticfiles_ app](https://docs.djangoproject.com/en/4.2/ref/contrib/staticfiles/#customizing-the-ignored-pattern-list).
# Installation
## Installation
Install the app into your environment:
@ -106,7 +108,7 @@ STATICFILES_DIRS = [
]
```
## Optional
### Optional
To avoid loading the app in each template using ``` {% load django_components %} ```, you can add the tag as a 'builtin' in settings.py
@ -128,7 +130,7 @@ TEMPLATES = [
Read on to find out how to build your first component!
# Compatiblity
## Compatiblity
Django-components supports all <a href="https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django">officially supported versions</a> of Django and Python.
@ -140,7 +142,7 @@ Django-components supports all <a href="https://docs.djangoproject.com/en/dev/fa
| 3.9 | 3.2, 4.0 |
| 3.10 | 4.0 |
# Create your first component
## Create your first component
A component in django-components is the combination of four things: CSS, Javascript, a Django template, and some Python code to put them all together.
@ -201,7 +203,7 @@ class Calendar(component.Component):
And voilá!! We've created our first component.
# Use the component in a template
## Use the component in a template
First load the `component_tags` tag library, then use the `component_[js/css]_dependencies` and `component` tags to render the component to the page.
@ -238,14 +240,16 @@ The output from the above template will be:
This makes it possible to organize your front-end around reusable components. Instead of relying on template tags and keeping your CSS and Javascript in the static directory.
# Using slots in templates
## Using slots in templates
_New in version 0.26_:
- The `slot` tag now serves only to declare new slots inside the component template.
- To override the content of a declared slot, use the newly introduced `fill` tag instead.
- Whereas unfilled slots used to raise a warning, filling a slot is now optional by default.
- To indicate that a slot must be filled, the new keyword `required` should be added at the end of the `slot` tag.
- To indicate that a slot must be filled, the new `required` option should be added at the end of the `slot` tag.
---
Components support something called 'slots'.
When a component is used inside another template, slots allow the parent template to override specific parts of the child component by passing in different content.
@ -254,9 +258,9 @@ 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 %}`: Declares a new slot in the component template.
- `{% fill <name> %}`/`{% endfill %}`: (Used inside a component block.) Fills a declared slot with the specified content.
- `{% fill <name> %}`/`{% endfill %}`: (Used inside a `component_block` tag pair.) Fills a declared slot with the specified content.
Let's update our calendar component to support more customization by updating our calendar.html template.
Let's update our calendar component to support more customization. We'll add `slot` tag pairs to its template, _calendar.html_.
```htmldjango
<div class="calendar-component">
@ -277,7 +281,7 @@ When using the component, you specify which slots you want to fill and where you
{% 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 pass in `date=2020-06-06`, this is what gets rendered:
```htmldjango
<div class="calendar-component">
@ -288,10 +292,84 @@ Since the header block is unspecified, it's taken from the base template. If you
Can you believe it's already <span>2020-06-06</span>??
</div>
</div>
```
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 in when you use a component. This makes for highly reusable components that can be used in different circumstances.
It can become tedious to use `fill` tags everywhere, especially when you're using a component that declares only one slot. To make things easier, `slot` tags can be marked with an optional keyword: `default`. When added to the end of the tag (as shown below), this option lets you pass filling content directly in the body of a `component_block` tag pair without using a `fill` tag. Choose carefully, though: a component template may contain at most one slot that is marked as `default`. The `default` option can be combined with other slot options, e.g. `required`.
Here's the same example as before, except with default slots and implicit filling.
The template:
```htmldjango
<div class="calendar-component">
<div class="header">
{% slot "header" %}Calendar header{% endslot %}
</div>
<div class="body">
{% slot "body" default %}Today's date is <span>{{ date }}</span>{% endslot %}
</div>
</div>
```
Including the component (notice how the `fill` tag is omitted):
```htmldjango
{% component_block "calendar" date="2020-06-06" %}
Can you believe it's already <span>{{ date }}</span>??
{% endcomponent_block %}
```
The rendered result (exactly the same as before):
```html
<div class="calendar-component">
<div class="header">
Calendar header
</div>
<div class="body">
Can you believe it's already <span>2020-06-06</span>??
</div>
</div>
```
You may be tempted to combine implicit fills with explicit `fill` tags. This will not work. The following component template will raise an error when compiled.
```htmldjango
{# DON'T DO THIS #}
{% component_block "calendar" date="2020-06-06" %}
{% fill "header" %}Totally new header!{% endfill %}
Can you believe it's already <span>{{ date }}</span>??
{% endcomponent_block %}
```
By contrast, it is permitted to use `fill` tags in nested components, e.g.:
```htmldjango
{% component_block "calendar" date="2020-06-06" %}
{% component_block "beautiful-box" %}
{% fill "content" %} Can you believe it's already <span>{{ date }}</span>?? {% endfill %}
{% endcomponent_block
{% endcomponent_block %}
```
This is fine too:
```htmldjango
{% component_block "calendar" date="2020-06-06" %}
{% fill "header" %}
{% component_block "calendar-header" %}
Super Special Calendar Header
{% endcomponent_block
{% endfill %}
{% endcomponent_block %}
```
### Advanced
#### Re-using content defined in the original slot
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.
@ -314,9 +392,8 @@ Produces:
</div>
```
## Advanced
### Conditional slots
#### Conditional slots
_Added in version 0.26._
@ -407,7 +484,7 @@ only if the slot 'subtitle' is _not_ filled.
{% 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):
@ -420,11 +497,11 @@ NOTE: `{% csrf_token %}` tags need access to the top-level context, and they wil
Components can also access the outer context in their context methods by accessing the property `outer_context`.
# Available settings
## Available settings
All library settings are handled from a global COMPONENTS variable that is read from settings.py. By default you don't need it set, there are resonable defaults.
## Configure the module where components are loaded from
### Configure the module where components are loaded from
Configure the location where components are loaded. To do this, add a COMPONENTS variable to you settings.py with a list of python paths to load. This allows you to build a structure of components that are independent from your apps.
@ -438,7 +515,7 @@ COMPONENTS = {
}
```
## Disable autodiscovery
### Disable autodiscovery
If you specify all the component locations with the setting above and have a lot of apps, you can (very) slightly speed things up by disabling autodiscovery.
@ -448,7 +525,7 @@ COMPONENTS = {
}
```
## Tune the template cache
### Tune the template cache
Each time a template is rendered it is cached to a global in-memory cache (using Python's lru_cache decorator). This speeds up the next render of the component. As the same component is often used many times on the same page, these savings add up. By default the cache holds 128 component templates in memory, which should be enough for most sites. But if you have a lot of components, or if you are using the `template` method of a component to render lots of dynamic templates, you can increase this number. To remove the cache limit altogether and cache everything, set template_cache_size to `None`.
@ -458,7 +535,7 @@ COMPONENTS = {
}
```
# Install locally and run the tests
## Install locally and run the tests
Start by forking the project by clicking the **Fork button** up in the right corner in the GitHub . This makes a copy of the repository in your own name. Now you can clone this repository locally and start adding features:

View file

@ -1,38 +1,35 @@
from typing import (
TYPE_CHECKING,
ClassVar,
Dict,
Iterator,
List,
Optional,
TypeVar,
)
from collections import ChainMap
from typing import Any, ClassVar, Dict, Iterable, Optional, Tuple, Union
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import MediaDefiningClass
from django.template import Context, TemplateSyntaxError
from django.template.base import Node, NodeList, Template
from django.forms.widgets import Media, MediaDefiningClass
from django.template.base import NodeList, Template
from django.template.context import Context
from django.template.exceptions import TemplateSyntaxError
from django.template.loader import get_template
from django.utils.safestring import mark_safe
# Allow "component.AlreadyRegistered" instead of having to import these everywhere
from django_components.component_registry import ( # noqa
# Global registry var and register() function moved to separate module.
# Defining them here made little sense, since 1) component_tags.py and component.py
# rely on them equally, and 2) it made it difficult to avoid circularity in the
# way the two modules depend on one another.
from django_components.component_registry import ( # NOQA
AlreadyRegistered,
ComponentRegistry,
NotRegistered,
register,
registry,
)
from django_components.templatetags.component_tags import (
FILLED_SLOTS_CONTENT_CONTEXT_KEY,
DefaultFillContent,
FillContent,
FilledSlotsContext,
IfSlotFilledConditionBranchNode,
NamedFillContent,
SlotName,
SlotNode,
)
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):
@ -65,23 +62,41 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
# Must be set on subclass OR subclass must implement get_template_name() with
# non-null return.
template_name: ClassVar[str]
media: Media
def __init__(self, component_name):
self._component_name: str = component_name
self._instance_fills: Optional[List["FillNode"]] = None
self._outer_context: Optional[dict] = None
class Media:
css = {}
js = []
def get_context_data(self, *args, **kwargs):
def __init__(
self,
registered_name: Optional[str] = None,
outer_context: Optional[Context] = None,
fill_content: Union[
DefaultFillContent, Iterable[NamedFillContent]
] = (),
):
self.registered_name: Optional[str] = registered_name
self.outer_context: Context = outer_context or Context()
self.fill_content = fill_content
def get_context_data(self, *args, **kwargs) -> Dict[str, Any]:
return {}
# Can be overridden for dynamic templates
def get_template_name(self, context):
if not hasattr(self, "template_name") or not self.template_name:
def get_template_name(self, context) -> str:
try:
name = self.template_name
except AttributeError:
raise ImproperlyConfigured(
f"Template name is not set for Component {self.__class__.__name__}"
f"Template name is not set for Component {type(self).__name__}. "
f"Note: this attribute is not required if you are overriding any of "
f"the class's `get_template*()` methods."
)
return name
return self.template_name
def get_template_string(self, context) -> str:
...
def render_dependencies(self):
"""Helper function to access media.render()"""
@ -95,125 +110,106 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
"""Render only JS dependencies available in the media class."""
return mark_safe("\n".join(self.media.render_js()))
def get_declared_slots(
self, context: Context, template: Optional[Template] = None
) -> List["SlotNode"]:
if template is None:
template = self.get_template(context)
return list(
dfs_iter_slots_in_nodelist(template.nodelist, template.name)
)
def get_template(self, context, template_name: Optional[str] = None):
if template_name is None:
def get_template(self, context) -> Template:
template_string = self.get_template_string(context)
if template_string is not None:
return Template(template_string)
else:
template_name = self.get_template_name(context)
template = get_template(template_name).template
return template
def set_instance_fills(self, fills: Dict[str, "FillNode"]) -> None:
self._instance_fills = fills
def set_outer_context(self, context):
self._outer_context = context
@property
def instance_fills(self):
return self._instance_fills or {}
@property
def outer_context(self):
return self._outer_context or {}
def get_updated_fill_stacks(self, context):
current_fill_stacks: Optional[Dict[str, List[FillNode]]] = context.get(
FILLED_SLOTS_CONTEXT_KEY, None
)
updated_fill_stacks = {}
if current_fill_stacks:
for name, fill_nodes in current_fill_stacks.items():
updated_fill_stacks[name] = list(fill_nodes)
for name, fill in self.instance_fills.items():
if name in updated_fill_stacks:
updated_fill_stacks[name].append(fill)
else:
updated_fill_stacks[name] = [fill]
return updated_fill_stacks
def validate_fills_and_slots_(
self,
context,
template: Template,
fills: Optional[Dict[str, "FillNode"]] = None,
) -> None:
if fills is None:
fills = self.instance_fills
all_slots: List["SlotNode"] = self.get_declared_slots(
context, template
)
slots: Dict[str, "SlotNode"] = {}
# Each declared slot must have a unique name.
for slot in all_slots:
slot_name = slot.name
if slot_name in slots:
raise TemplateSyntaxError(
f"Encountered non-unique slot '{slot_name}' in template "
f"'{template.name}' of component '{self._component_name}'."
)
slots[slot_name] = slot
# All fill nodes must correspond to a declared slot.
unmatchable_fills = fills.keys() - slots.keys()
if unmatchable_fills:
msg = (
f"Component '{self._component_name}' passed fill(s) "
f"refering to undefined slot(s). Bad fills: {list(unmatchable_fills)}."
)
raise TemplateSyntaxError(msg)
# Note: Requirement that 'required' slots be filled is enforced
# in SlotNode.render().
template: Template = get_template(template_name).template
return template
def render(self, context):
template_name = self.get_template_name(context)
template = self.get_template(context, template_name)
self.validate_fills_and_slots_(context, template)
updated_fill_stacks = self.get_updated_fill_stacks(context)
with context.update({FILLED_SLOTS_CONTEXT_KEY: updated_fill_stacks}):
template = self.get_template(context)
updated_filled_slots_context: FilledSlotsContext = (
self._process_template_and_update_filled_slot_context(
context, template
)
)
with context.update(
{FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_filled_slots_context}
):
return template.render(context)
class Media:
css = {}
js = []
def dfs_iter_slots_in_nodelist(
nodelist: NodeList, template_name: str = None
) -> Iterator["SlotNode"]:
from django_components.templatetags.component_tags import SlotNode
nodes: List[Node] = list(nodelist)
while nodes:
node = nodes.pop()
if isinstance(node, SlotNode):
yield node
for nodelist_name in node.child_nodelists:
nodes.extend(reversed(getattr(node, nodelist_name, [])))
# This variable represents the global component registry
registry = ComponentRegistry()
def register(name):
"""Class decorator to register a component.
Usage:
@register("my_component")
class MyComponent(component.Component):
...
"""
def decorator(component):
registry.register(name=name, component=component)
return component
return decorator
def _process_template_and_update_filled_slot_context(
self, context: Context, template: Template
) -> FilledSlotsContext:
fill_target2content: Dict[Optional[str], FillContent]
if isinstance(self.fill_content, NodeList):
fill_target2content = {None: (self.fill_content, None)}
else:
fill_target2content = {
name: (nodelist, alias)
for name, nodelist, alias in self.fill_content
}
slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {}
default_slot_already_encountered: bool = False
for node in template.nodelist.get_nodes_by_type(
(SlotNode, IfSlotFilledConditionBranchNode) # type: ignore
):
if isinstance(node, SlotNode):
# Give slot node knowledge of its parent template.
node.template = template
slot_name = node.name
if slot_name in slot_name2fill_content:
raise TemplateSyntaxError(
f"Slot name '{slot_name}' re-used within the same template. "
f"Slot names must be unique."
f"To fix, check template '{template.name}' "
f"of component '{self.registered_name}'."
)
content_data: Optional[
FillContent
] = None # `None` -> unfilled
if node.is_default:
if default_slot_already_encountered:
raise TemplateSyntaxError(
"Only one component slot may be marked as 'default'. "
f"To fix, check template '{template.name}' "
f"of component '{self.registered_name}'."
)
default_slot_already_encountered = True
content_data = fill_target2content.get(None)
if not content_data:
content_data = fill_target2content.get(node.name)
if not content_data and node.is_required:
raise TemplateSyntaxError(
f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), "
f"yet no fill is provided. Check template.'"
)
slot_name2fill_content[slot_name] = content_data
elif isinstance(node, IfSlotFilledConditionBranchNode):
node.template = template
else:
raise RuntimeError(
f"Node of {type(node).__name__} does not require linking."
)
# Check fills
if (
None in fill_target2content
and not default_slot_already_encountered
):
raise TemplateSyntaxError(
f"Component '{self.registered_name}' passed default fill content "
f"(i.e. without explicit 'fill' tag), "
f"even though none of its slots is marked as 'default'."
)
for fill_name in filter(None, fill_target2content.keys()):
if fill_name not in slot_name2fill_content:
raise TemplateSyntaxError(
f"Component '{self.registered_name}' passed fill "
f"that refers to undefined slot: {fill_name}"
)
# Return updated FILLED_SLOTS_CONTEXT map
filled_slots_map: Dict[Tuple[SlotName, Template], FillContent] = {
(slot_name, template): content_data
for slot_name, content_data in slot_name2fill_content.items()
if content_data # (is not None)
}
try:
prev_context: FilledSlotsContext = context[
FILLED_SLOTS_CONTENT_CONTEXT_KEY
]
return prev_context.new_child(filled_slots_map)
except KeyError:
return ChainMap(filled_slots_map)

View file

@ -34,3 +34,24 @@ class ComponentRegistry(object):
def clear(self):
self._registry = {}
# This variable represents the global component registry
registry = ComponentRegistry()
def register(name):
"""Class decorator to register a component.
Usage:
@register("my_component")
class MyComponent(component.Component):
...
"""
def decorator(component):
registry.register(name=name, component=component)
return component
return decorator

View file

@ -4,6 +4,8 @@ from django.conf import settings
from django.forms import Media
from django.http import StreamingHttpResponse
from django_components.component_registry import registry
RENDERED_COMPONENTS_CONTEXT_KEY = "_COMPONENT_DEPENDENCIES"
CSS_DEPENDENCY_PLACEHOLDER = '<link name="CSS_PLACEHOLDER">'
JS_DEPENDENCY_PLACEHOLDER = '<script name="JS_PLACEHOLDER"></script>'
@ -39,8 +41,6 @@ class ComponentDependencyMiddleware:
def process_response_content(content):
from django_components.component import registry
component_names_seen = {
match.group("name")
for match in COMPONENT_COMMENT_REGEX.finditer(content)

View file

@ -1,20 +1,28 @@
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
import sys
from typing import TYPE_CHECKING, Iterable, List, Optional, Tuple, Type, Union
from django import template
if sys.version_info[:2] < (3, 9):
from typing import ChainMap
else:
from collections import ChainMap
import django.template
from django.conf import settings
from django.template import Context
from django.template import Context, Template
from django.template.base import (
FilterExpression,
Node,
NodeList,
TemplateSyntaxError,
TextNode,
TokenType,
Variable,
VariableDoesNotExist,
)
from django.template.defaulttags import CommentNode
from django.template.exceptions import TemplateSyntaxError
from django.template.library import parse_bits
from django.utils.safestring import mark_safe
from django_components.component import FILLED_SLOTS_CONTEXT_KEY, registry
from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as component_registry
from django_components.middleware import (
CSS_DEPENDENCY_PLACEHOLDER,
JS_DEPENDENCY_PLACEHOLDER,
@ -23,13 +31,30 @@ from django_components.middleware import (
if TYPE_CHECKING:
from django_components.component import Component
register = template.Library()
register = django.template.Library()
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
SLOT_REQUIRED_OPTION_KEYWORD = "required"
SLOT_DEFAULT_OPTION_KEYWORD = "default"
def get_components_from_registry(registry):
FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
# Type aliases
SlotName = str
AliasName = str
DefaultFillContent = NodeList
NamedFillContent = Tuple[SlotName, NodeList, Optional[AliasName]]
FillContent = Tuple[NodeList, Optional[AliasName]]
FilledSlotsContext = ChainMap[Tuple[SlotName, Template], FillContent]
def get_components_from_registry(registry: ComponentRegistry):
"""Returns a list unique components from the registry."""
unique_component_classes = set(registry.all().values())
@ -49,7 +74,7 @@ def get_components_from_preload_str(preload_str):
component_name = component_name.strip()
if not component_name:
continue
component_class = registry.get(component_name)
component_class = component_registry.get(component_name)
components.append(component_class(component_name))
return components
@ -64,7 +89,7 @@ def component_dependencies_tag(preload=""):
for component in get_components_from_preload_str(preload):
preloaded_dependencies.append(
RENDERED_COMMENT_TEMPLATE.format(
name=component._component_name
name=component.registered_name
)
)
return mark_safe(
@ -74,7 +99,7 @@ def component_dependencies_tag(preload=""):
)
else:
rendered_dependencies = []
for component in get_components_from_registry(registry):
for component in get_components_from_registry(component_registry):
rendered_dependencies.append(component.render_dependencies())
return mark_safe("\n".join(rendered_dependencies))
@ -89,7 +114,7 @@ def component_css_dependencies_tag(preload=""):
for component in get_components_from_preload_str(preload):
preloaded_dependencies.append(
RENDERED_COMMENT_TEMPLATE.format(
name=component._component_name
name=component.registered_name
)
)
return mark_safe(
@ -97,7 +122,7 @@ def component_css_dependencies_tag(preload=""):
)
else:
rendered_dependencies = []
for component in get_components_from_registry(registry):
for component in get_components_from_registry(component_registry):
rendered_dependencies.append(component.render_css_dependencies())
return mark_safe("\n".join(rendered_dependencies))
@ -112,7 +137,7 @@ def component_js_dependencies_tag(preload=""):
for component in get_components_from_preload_str(preload):
preloaded_dependencies.append(
RENDERED_COMMENT_TEMPLATE.format(
name=component._component_name
name=component.registered_name
)
)
return mark_safe(
@ -120,7 +145,7 @@ def component_js_dependencies_tag(preload=""):
)
else:
rendered_dependencies = []
for component in get_components_from_registry(registry):
for component in get_components_from_registry(component_registry):
rendered_dependencies.append(component.render_js_dependencies())
return mark_safe("\n".join(rendered_dependencies))
@ -134,7 +159,7 @@ def do_component(parser, token):
parser, bits, "component"
)
return ComponentNode(
NameVariable(component_name, tag="component"),
FilterExpression(component_name, parser),
context_args,
context_kwargs,
isolated_context=isolated_context,
@ -161,44 +186,78 @@ class UserSlotVar:
return mark_safe(self._slot.nodelist.render(self._context))
class SlotNode(Node):
class TemplateAwareNodeMixin:
_template: Template
@property
def template(self) -> Template:
try:
return self._template
except AttributeError:
raise RuntimeError(
f"Internal error: Instance of {type(self).__name__} was not "
"linked to Template before use in render() context."
)
@template.setter
def template(self, value) -> None:
self._template = value
class SlotNode(Node, TemplateAwareNodeMixin):
def __init__(
self, name, nodelist, template_name: str = "", required=False
self,
name: str,
nodelist: NodeList,
is_required: bool = False,
is_default: bool = False,
):
self.name = name
self.nodelist = nodelist
self.template_name = template_name
self.is_required = required
self.is_required = is_required
self.is_default = is_default
@property
def active_flags(self):
m = []
if self.is_required:
m.append("required")
if self.is_default:
m.append("default")
return m
def __repr__(self):
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}>"
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
def render(self, context):
if FILLED_SLOTS_CONTEXT_KEY not in context:
try:
filled_slots_map: FilledSlotsContext = context[
FILLED_SLOTS_CONTENT_CONTEXT_KEY
]
except KeyError:
raise TemplateSyntaxError(
f"Attempted to render SlotNode '{self.name}' outside a parent component."
)
filled_slots: Dict[str, List[FillNode]] = context[
FILLED_SLOTS_CONTEXT_KEY
]
fill_node_stack = filled_slots.get(self.name, None)
extra_context = {}
if not fill_node_stack: # if None or []
nodelist = self.nodelist
# Raise if slot is 'required'
try:
slot_fill_content: Optional[FillContent] = filled_slots_map[
(self.name, self.template)
]
except KeyError:
if self.is_required:
raise TemplateSyntaxError(
f"Slot '{self.name}' is marked as 'required' (i.e. non-optional), "
f"yet no fill is provided. Check template '{self.template_name}'"
f"yet no fill is provided. "
)
nodelist = self.nodelist
else:
fill_node = fill_node_stack.pop()
nodelist = fill_node.nodelist
nodelist, alias = slot_fill_content
if alias:
if not alias.isidentifier():
raise TemplateSyntaxError()
extra_context[alias] = UserSlotVar(self, context)
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)
@ -208,60 +267,88 @@ def do_slot(parser, token):
bits = token.split_contents()
args = bits[1:]
# e.g. {% slot <name> %}
if len(args) == 1:
slot_name: str = args[0]
required = False
elif len(args) == 2:
slot_name: str = args[0]
required_keyword = args[1]
if required_keyword != "required":
is_required = False
is_default = False
if 1 <= len(args) <= 3:
slot_name, *options = args
if not is_wrapped_in_quotes(slot_name):
raise TemplateSyntaxError(
f"'{bits[0]}' only accepts 'required' keyword as optional second argument"
f"'{bits[0]}' name must be a string 'literal'."
)
slot_name = strip_quotes(slot_name)
modifiers_count = len(options)
if SLOT_REQUIRED_OPTION_KEYWORD in options:
is_required = True
modifiers_count -= 1
if SLOT_DEFAULT_OPTION_KEYWORD in options:
is_default = True
modifiers_count -= 1
if modifiers_count != 0:
keywords = [
SLOT_REQUIRED_OPTION_KEYWORD,
SLOT_DEFAULT_OPTION_KEYWORD,
]
raise TemplateSyntaxError(
f"Invalid options passed to 'slot' tag. Valid choices: {keywords}."
)
else:
required = True
else:
raise TemplateSyntaxError(
f"{bits[0]}' tag takes only one argument (the slot name)"
"'slot' tag does not match pattern "
"{% slot <name> ['default'] ['required'] %}. "
"Order of options is free."
)
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])
nodelist = parser.parse(parse_until=["endslot"])
parser.delete_first_token()
template_name = parser.origin.template_name
return SlotNode(slot_name, nodelist, template_name, required)
return SlotNode(
slot_name,
nodelist,
is_required=is_required,
is_default=is_default,
)
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
class BaseFillNode(Node):
def __init__(self, nodelist: NodeList):
self.nodelist: NodeList = nodelist
def __repr__(self):
return f"<Fill Node: {self.name_var}. Contents: {repr(self.nodelist)}>"
raise NotImplementedError
def render(self, context):
raise TemplateSyntaxError(
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."
"{% fill ... %} block cannot be rendered directly. "
"You are probably seeing this because you have used one outside "
"a {% component_block %} context."
)
class NamedFillNode(BaseFillNode):
def __init__(
self,
nodelist: NodeList,
name_fexp: FilterExpression,
alias_fexp: Optional[FilterExpression] = None,
):
super().__init__(nodelist)
self.name_fexp = name_fexp
self.alias_fexp = alias_fexp
def __repr__(self):
return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>"
class ImplicitFillNode(BaseFillNode):
"""
Instantiated when a `component_block` tag pair is passed template content that
excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked
as 'default'.
"""
def __repr__(self):
return f"<{type(self)} Contents: {repr(self.nodelist)}.>"
@register.tag("fill")
def do_fill(parser, token):
"""Block tag whose contents 'fill' (are inserted into) an identically named
@ -275,7 +362,7 @@ def do_fill(parser, token):
tag = bits[0]
args = bits[1:]
# e.g. {% fill <name> %}
alias_var = None
alias_fexp: Optional[FilterExpression] = None
if len(args) == 1:
tgt_slot_name: str = args[0]
# e.g. {% fill <name> as <alias> %}
@ -285,49 +372,56 @@ def do_fill(parser, token):
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")
alias_fexp = FilterExpression(alias, parser)
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)
return NamedFillNode(
nodelist,
name_fexp=FilterExpression(tgt_slot_name, tag),
alias_fexp=alias_fexp,
)
class ComponentNode(Node):
child_nodelists = ("fill_nodes",)
def __init__(
self,
name_var: "NameVariable",
name_fexp: FilterExpression,
context_args,
context_kwargs,
isolated_context=False,
fill_nodes: Union[ImplicitFillNode, Iterable[NamedFillNode]] = (),
):
self.name_var = name_var
self.name_fexp = name_fexp
self.context_args = context_args or []
self.context_kwargs = context_kwargs or {}
self.fill_nodes: NodeList[FillNode] = NodeList()
self.isolated_context = isolated_context
self.fill_nodes = fill_nodes
@property
def nodelist(self) -> Union[NodeList, Node]:
if isinstance(self.fill_nodes, ImplicitFillNode):
return NodeList([self.fill_nodes])
else:
return NodeList(self.fill_nodes)
def __repr__(self):
return "<Component Node: %s. Contents: %r>" % (
self.name_var,
return "<ComponentNode: %s. Contents: %r>" % (
self.name_fexp,
getattr(
self, "nodelist", None
), # 'nodelist' attribute only assigned later.
)
def render(self, context):
resolved_component_name = self.name_var.resolve(context)
component_cls = registry.get(resolved_component_name)
component: Component = component_cls(resolved_component_name)
def render(self, context: Context):
resolved_component_name = self.name_fexp.resolve(context)
component_cls: Type[Component] = component_registry.get(
resolved_component_name
)
# Resolve FilterExpressions and Variables that were passed as args to the
# component, then call component's context method
@ -340,17 +434,38 @@ class ComponentNode(Node):
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
}
if isinstance(self.fill_nodes, ImplicitFillNode):
fill_content = self.fill_nodes.nodelist
else:
fill_content = []
for fill_node in self.fill_nodes:
# Note that outer component context is used to resolve variables in
# fill tag.
resolved_name = fill_node.name_fexp.resolve(context)
if fill_node.alias_fexp:
resolved_alias: str = fill_node.alias_fexp.resolve(context)
if not resolved_alias.isidentifier():
raise TemplateSyntaxError(
f"Fill tag alias '{fill_node.alias_fexp.var}' in component "
f"{resolved_component_name} does not resolve to "
f"a valid Python identifier. Got: '{resolved_alias}'."
)
else:
resolved_alias: None = None
fill_content.append(
(resolved_name, fill_node.nodelist, resolved_alias)
)
component.set_instance_fills(resolved_fills)
component.set_outer_context(context)
component: Component = component_cls(
registered_name=resolved_component_name,
outer_context=context,
fill_content=fill_content,
)
component_context = component.get_context_data(
component_context: dict = component.get_context_data(
*resolved_context_args, **resolved_context_kwargs
)
if self.isolated_context:
context = context.new()
with context.update(component_context):
@ -358,9 +473,7 @@ class ComponentNode(Node):
if is_dependency_middleware_active():
return (
RENDERED_COMMENT_TEMPLATE.format(
name=component._component_name
)
RENDERED_COMMENT_TEMPLATE.format(name=resolved_component_name)
+ rendered_component
)
else:
@ -387,71 +500,100 @@ def do_component_block(parser, token):
component_name, context_args, context_kwargs = parse_component_with_args(
parser, bits, "component_block"
)
body: NodeList = parser.parse(parse_until=["endcomponent_block"])
parser.delete_first_token()
fill_nodes = ()
if block_has_content(body):
for parse_fn in (
try_parse_as_default_fill,
try_parse_as_named_fill_tag_set,
):
fill_nodes = parse_fn(body)
if fill_nodes:
break
else:
raise TemplateSyntaxError(
"Illegal content passed to 'component_block' tag pair. "
"Possible causes: 1) Explicit 'fill' tags cannot occur alongside other "
"tags except comment tags; 2) Default (default slot-targeting) content "
"is mixed with explict 'fill' tags."
)
component_node = ComponentNode(
NameVariable(component_name, "component"),
FilterExpression(component_name, parser),
context_args,
context_kwargs,
isolated_context=isolated_context,
fill_nodes=fill_nodes,
)
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)
return component_node
def fill_tokens(parser):
"""Yield each 'fill' token appearing before the next 'endcomponent_block' token.
def try_parse_as_named_fill_tag_set(
nodelist: NodeList,
) -> Optional[Iterable[NamedFillNode]]:
result = []
seen_name_fexps = set()
for node in nodelist:
if isinstance(node, NamedFillNode):
if node.name_fexp in seen_name_fexps:
raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: "
f"Detected duplicate fill tag name '{node.name_fexp}'."
)
result.append(node)
elif isinstance(node, CommentNode):
pass
elif isinstance(node, TextNode) and node.s.isspace():
pass
else:
return None
return result
Raises TemplateSyntaxError if:
- there are other content tokens
- there is no endcomponent_block token.
- a (deprecated) 'slot' token is encountered.
"""
def is_whitespace(token):
return (
token.token_type == TokenType.TEXT and not token.contents.strip()
)
def try_parse_as_default_fill(
nodelist: NodeList,
) -> Optional[ImplicitFillNode]:
# nodelist.get_nodes_by_type()
nodes_stack: List[Node] = list(nodelist)
while nodes_stack:
node = nodes_stack.pop()
if isinstance(node, NamedFillNode):
return None
elif isinstance(node, ComponentNode):
# Stop searching here, as fill tags are permitted inside component blocks
# embedded within a default fill node.
continue
for nodelist_attr_name in node.child_nodelists:
nodes_stack.extend(getattr(node, nodelist_attr_name, []))
else:
return ImplicitFillNode(nodelist=nodelist)
def is_block_tag(token, name):
return (
token.token_type == TokenType.BLOCK
and token.split_contents()[0] == name
)
while True:
try:
token = parser.next_token()
except IndexError:
raise TemplateSyntaxError("Unclosed component_block tag")
if is_block_tag(token, name="endcomponent_block"):
return
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(
"Component block EITHER contains illegal tokens tag that are not "
"{{% fill ... %}} tags OR the proper closing tag -- "
"{{% endcomponent_block %}} -- is missing."
)
def block_has_content(nodelist) -> bool:
for node in nodelist:
if isinstance(node, TextNode) and node.s.isspace():
pass
elif isinstance(node, CommentNode):
pass
else:
return True
return False
def is_whitespace_node(node: Node) -> bool:
return isinstance(node, TextNode) and node.s.isspace()
def is_whitespace_token(token):
return token.token_type == TokenType.TEXT and not token.contents.strip()
def is_block_tag_token(token, name):
return (
token.token_type == TokenType.BLOCK
and token.split_contents()[0] == name
)
@register.tag(name="if_filled")
@ -480,11 +622,12 @@ def do_if_filled_block(parser, token):
"""
bits = token.split_contents()
starting_tag = bits[0]
slot_name_var: Optional[NameVariable]
slot_name_var, is_positive = parse_if_filled_bits(bits)
slot_name, 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)
branches: List[_IfSlotFilledBranchNode] = [
IfSlotFilledConditionBranchNode(
slot_name=slot_name, nodelist=nodelist, is_positive=is_positive
)
]
token = parser.next_token()
@ -492,11 +635,16 @@ def do_if_filled_block(parser, 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)
slot_name, 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))
branches.append(
IfSlotFilledConditionBranchNode(
slot_name=slot_name, nodelist=nodelist, is_positive=is_positive
)
)
token = parser.next_token()
# {% else_filled %} (optional)
@ -504,7 +652,7 @@ def do_if_filled_block(parser, token):
bits = token.split_contents()
_, _ = parse_if_filled_bits(bits)
nodelist = parser.parse(("endif_filled",))
branches.append((None, nodelist, None))
branches.append(IfSlotFilledElseBranchNode(nodelist))
token = parser.next_token()
# {% endif_filled %}
@ -519,13 +667,12 @@ def do_if_filled_block(parser, token):
def parse_if_filled_bits(
bits: List[str],
) -> Tuple[Optional["NameVariable"], Optional[bool]]:
) -> Tuple[Optional[str], 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)}'"
f"Tag '{tag}' takes no arguments. Received '{' '.join(args)}'"
)
else:
return None, None
@ -540,48 +687,81 @@ def parse_if_filled_bits(
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
if not is_wrapped_in_quotes(slot_name):
raise TemplateSyntaxError(
f"First argument of '{bits[0]}' must be a quoted string 'literal'."
)
slot_name = strip_quotes(slot_name)
return slot_name, is_positive
class _IfSlotFilledBranchNode(Node):
def __init__(self, nodelist: NodeList) -> None:
self.nodelist = nodelist
def render(self, context: Context) -> str:
return self.nodelist.render(context)
def evaluate(self, context) -> bool:
raise NotImplementedError
class IfSlotFilledConditionBranchNode(
_IfSlotFilledBranchNode, TemplateAwareNodeMixin
):
def __init__(
self,
slot_name: str,
nodelist: NodeList,
is_positive=True,
) -> None:
self.slot_name = slot_name
self.is_positive: bool = is_positive
super().__init__(nodelist)
def evaluate(self, context) -> bool:
try:
filled_slots: FilledSlotsContext = context[
FILLED_SLOTS_CONTENT_CONTEXT_KEY
]
except KeyError:
raise TemplateSyntaxError(
f"Attempted to render {type(self).__name__} outside a Component rendering context."
)
slot_key = (self.slot_name, self.template)
is_filled = filled_slots.get(slot_key, None) is not None
# Make polarity switchable.
# i.e. if slot name is NOT filled and is_positive=False,
# then False == False -> True
return is_filled == self.is_positive
class IfSlotFilledElseBranchNode(_IfSlotFilledBranchNode):
def evaluate(self, context) -> bool:
return True
class IfSlotFilledNode(Node):
def __init__(
self,
branches: List[
Tuple[Optional["NameVariable"], NodeList, Optional[bool]]
],
branches: List[_IfSlotFilledBranchNode],
):
# [(<slot name var | None (= condition)>, nodelist, <is_positive>)]
self.branches = branches
def __iter__(self):
for _, nodelist, _ in self.branches:
for node in nodelist:
yield node
def __repr__(self):
return f"<{self.__class__.__name__}>"
@property
def nodelist(self):
return NodeList(self)
return NodeList(self.branches)
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
for node in self.branches:
if isinstance(node, IfSlotFilledElseBranchNode):
return node.render(context)
elif isinstance(node, IfSlotFilledConditionBranchNode):
if node.evaluate(context):
return node.render(context)
return ""
@ -649,7 +829,7 @@ def is_dependency_middleware_active():
)
def norm_and_validate_name(name: str, tag: str, context: str = None):
def norm_and_validate_name(name: str, tag: str, context: Optional[str] = None):
"""
Notes:
- Value of `tag` in {"slot", "fill", "alias"}
@ -664,19 +844,6 @@ def norm_and_validate_name(name: str, tag: str, context: str = None):
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("\"'")
@ -689,18 +856,3 @@ def bool_from_string(s: str):
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

@ -9,5 +9,11 @@
{% endfill %}
{% endcomponent_block %}
</li>
<li>
{% component_block "todo" %}
{# As of v0.28, 'fill' tag optional for 1-slot filling if component template specifies a 'default' slot #}
Wear all-white clothes to laser tag tournament.
{% endcomponent_block %}
</li>
</ul>
</div>

View file

@ -1,4 +1,4 @@
<div class="todo-item">
{% slot "todo_text" %}
{% slot "todo_text" default %}
{% endslot %}
</div>

View file

@ -0,0 +1,5 @@
{% load component_tags %}
<div>
<header>{% slot "header" %}Your Header Here{% endslot %}</header>
<main>{% slot "main" default required %}Easy to override{% endslot %}</main>
</div>

View file

@ -0,0 +1,4 @@
{% load component_tags %}
<div>
<main>{% slot "main" default %}Easy to override{% endslot %}</main>
</div>

View file

@ -2,7 +2,7 @@
{% load component_tags %}
<div class="frontmatter-component">
<div class="title">{% slot "title" %}Title{% endslot %}</div>
{% if_filled "subtitle" %}
{% 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>

View file

@ -1,6 +1,6 @@
import re
from textwrap import dedent
from typing import Callable
from typing import Callable, Optional
from django.template import Context, Template, TemplateSyntaxError
@ -77,6 +77,14 @@ class _DashboardComponent(component.Component):
template_name = "slotted_component_nesting_template_pt2_dashboard.html"
class ComponentWithDefaultSlot(component.Component):
template_name = "template_with_default_slot.html"
class ComponentWithDefaultAndRequiredSlot(component.Component):
template_name = "template_with_default_and_required_slot.html"
class ComponentTemplateTagTest(SimpleTestCase):
def setUp(self):
# NOTE: component.registry is global, so need to clear before each test
@ -349,6 +357,154 @@ class ComponentSlottedTemplateTagTest(SimpleTestCase):
with self.assertRaises(TemplateSyntaxError):
template.render(Context({}))
def test_default_slot_is_fillable_by_implicit_fill_content(self):
component.registry.register("test_comp", ComponentWithDefaultSlot)
template = Template(
"""
{% load component_tags %}
{% component_block 'test_comp' %}
<p>This fills the 'main' slot.</p>
{% endcomponent_block %}
"""
)
expected = """
<div>
<main><p>This fills the 'main' slot.</p></main>
</div>
"""
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, expected)
def test_default_slot_is_fillable_by_explicit_fill_content(self):
component.registry.register("test_comp", ComponentWithDefaultSlot)
template = Template(
"""
{% load component_tags %}
{% component_block 'test_comp' %}
{% fill "main" %}<p>This fills the 'main' slot.</p>{% endfill %}
{% endcomponent_block %}
"""
)
expected = """
<div>
<main><p>This fills the 'main' slot.</p></main>
</div>
"""
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, expected)
def test_error_raised_when_default_and_required_slot_not_filled(self):
component.registry.register(
"test_comp", ComponentWithDefaultAndRequiredSlot
)
template = Template(
"""
{% load component_tags %}
{% component_block 'test_comp' %}
{% endcomponent_block %}
"""
)
with self.assertRaises(TemplateSyntaxError):
template.render(Context({}))
def test_fill_tag_can_occur_within_component_block_nested_in_implicit_fill(
self,
):
component.registry.register("test_comp", ComponentWithDefaultSlot)
component.registry.register("slotted", SlottedComponent)
template = Template(
"""
{% load component_tags %}
{% component_block 'test_comp' %}
{% component_block "slotted" %}
{% fill "header" %}This Is Allowed{% endfill %}
{% fill "main" %}{% endfill %}
{% fill "footer" %}{% endfill %}
{% endcomponent_block %}
{% endcomponent_block %}
"""
)
expected = """
<div>
<main>
<custom-template>
<header>This Is Allowed</header>
<main></main>
<footer></footer>
</custom-template>
</main>
</div>
"""
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, expected)
def test_error_from_mixed_implicit_and_explicit_fill_content(self):
component.registry.register("test_comp", ComponentWithDefaultSlot)
with self.assertRaises(TemplateSyntaxError):
Template(
"""
{% load component_tags %}
{% component_block 'test_comp' %}
{% fill "main" %}Main content{% endfill %}
<p>And add this too!</p>
{% endcomponent_block %}
"""
)
def test_comments_permitted_inside_implicit_fill_content(self):
component.registry.register("test_comp", ComponentWithDefaultSlot)
Template(
"""
{% load component_tags %}
{% component_block 'test_comp' %}
<p>Main Content</p>
{% comment %}
This won't show up in the rendered HTML
{% endcomment %}
{# Nor will this #}
{% endcomponent_block %}
"""
)
self.assertTrue(True)
def test_component_without_default_slot_refuses_implicit_fill(self):
component.registry.register("test_comp", SlottedComponent)
template = Template(
"""
{% load component_tags %}
{% component_block 'test_comp' %}
<p>This shouldn't work because the included component doesn't mark
any of its slots as 'default'</p>
{% endcomponent_block %}
"""
)
with self.assertRaises(TemplateSyntaxError):
template.render(Context({}))
def test_component_template_cannot_have_multiple_default_slots(self):
class BadComponent(component.Component):
def get_template(
self, context, template_name: Optional[str] = None
) -> Template:
return Template(
"""
{% load django_components %}
<div>
{% slot "icon" %} {% endslot default %}
{% slot "description" %} {% endslot default %}
</div>
"""
)
c = BadComponent("name")
with self.assertRaises(TemplateSyntaxError):
c.render(Context({}))
class SlottedTemplateRegressionTests(SimpleTestCase):
def setUp(self):
@ -552,7 +708,7 @@ class NestedSlotTests(SimpleTestCase):
super().tearDownClass()
component.registry.clear()
def test_default_slots_render_correctly(self):
def test_default_slot_contents_render_correctly(self):
template = Template(
"""
{% load component_tags %}
@ -779,27 +935,31 @@ class TemplateSyntaxErrorTests(SimpleTestCase):
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" %}
{{ anything }}
{% endcomponent_block %}
def test_variable_outside_fill_tag_compiles_w_out_error(self):
# As of v0.28 this is valid, provided the component registered under "test"
# contains a slot tag marked as 'default'. This is verified outside
# template compilation time.
Template(
"""
)
{% load component_tags %}
{% component_block "test" %}
{{ anything }}
{% endcomponent_block %}
"""
)
def test_text_outside_fill_tag_is_error(self):
with self.assertRaises(TemplateSyntaxError):
Template(
"""
{% load component_tags %}
{% component_block "test" %}
Text
{% endcomponent_block %}
def test_text_outside_fill_tag_is_not_error(self):
# As of v0.28 this is valid, provided the component registered under "test"
# contains a slot tag marked as 'default'. This is verified outside
# template compilation time.
Template(
"""
)
{% load component_tags %}
{% component_block "test" %}
Text
{% endcomponent_block %}
"""
)
def test_nonfill_block_outside_fill_tag_is_error(self):
with self.assertRaises(TemplateSyntaxError):
@ -843,7 +1003,7 @@ class TemplateSyntaxErrorTests(SimpleTestCase):
{% fill "main" %}Custom main{% endfill %}
{% fill "footer" %}Custom footer{% endfill %}
{% endcomponent_block %}
"""
"""
).render(Context({}))
def test_non_unique_fill_names_is_error(self):
@ -855,7 +1015,7 @@ class TemplateSyntaxErrorTests(SimpleTestCase):
{% fill "header" %}Custom header {% endfill %}
{% fill "header" %}Other header{% endfill %}
{% endcomponent_block %}
"""
"""
).render(Context({}))
def test_non_unique_slot_names_is_error(self):
@ -1069,7 +1229,7 @@ class RegressionTests(SimpleTestCase):
super().tearDownClass()
component.registry.clear()
def test_extends_tag_works(self):
def test_block_and_extends_tag_works(self):
component.registry.register("slotted_component", SlottedComponent)
template = """
{% extends "extendable_template_with_blocks.html" %}