feat: dynamic slots, fills, and provides (#609)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2024-08-25 22:35:10 +02:00 committed by GitHub
parent 6793aec9b4
commit b90961b4a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 521 additions and 140 deletions

118
README.md
View file

@ -48,7 +48,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
- [Use components as views](#use-components-as-views)
- [Autodiscovery](#autodiscovery)
- [Using slots in templates](#using-slots-in-templates)
- [Passing data to components](#passing-data-to-components)
- [Accessing data passed to the component](#accessing-data-passed-to-the-component)
- [Rendering HTML attributes](#rendering-html-attributes)
- [Template tag syntax](#template-tag-syntax)
- [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject)
@ -68,6 +68,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
**Version 0.93**
- Spread operator `...dict` inside template tags. See [Spread operator](#spread-operator))
- Use template tags inside string literals in component inputs. See [Use template tags inside component inputs](#use-template-tags-inside-component-inputs))
- Dynamic slots, fills and provides - The `name` argument for these can now be a variable, a template expression, or via spread operator
🚨📢 **Version 0.92**
- BREAKING CHANGE: `Component` class is no longer a subclass of `View`. To configure the `View` class, set the `Component.View` nested class. HTTP methods like `get` or `post` can still be defined directly on `Component` class, and `Component.as_view()` internally calls `Component.View.as_view()`. (See [Modifying the View class](#modifying-the-view-class))
@ -812,7 +813,7 @@ class ComponentView(View):
If you want to define your own `View` class, you need to:
1. Set the class as `Component.View`
2. Subclass from `ComponentView`, so the View instance has access to the component class.
2. Subclass from `ComponentView`, so the View instance has access to the component instance.
In the example below, we added extra logic into `View.setup()`.
@ -1440,18 +1441,77 @@ to the same name. This raises an error:
{% endcomponent %}
```
## Passing data to components
### Dynamic slots and fills
As seen above, you can pass arguments to components like so:
Until now, we were declaring slot and fill names statically, as a string literal, e.g.
```django
<body>
{% component "calendar" date="2015-06-19" %}
{% endcomponent %}
</body>
{% slot "content" / %}
```
### Accessing data passed to the component
However, sometimes you may want to generate slots based on the given input. One example of this is [a table component like that of Vuetify](https://vuetifyjs.com/en/api/v-data-table/), which creates a header and an item slots for each user-defined column.
In django_components you can achieve the same, simply by using a variable (or a [template expression](#use-template-tags-inside-component-inputs)) instead of a string literal:
```django
<table>
<tr>
{% for header in headers %}
<th>
{% slot "header-{{ header.key }}" value=header.title %}
{{ header.title }}
{% endslot %}
</th>
{% endfor %}
</tr>
</table>
```
When using the component, you can either set the fill explicitly:
```django
{% component "table" headers=headers items=items %}
{% fill "header-name" data="data" %}
<b>{{ data.value }}</b>
{% endfill %}
{% endcomponent %}
```
Or also use a variable:
```django
{% component "table" headers=headers items=items %}
{# Make only the active column bold #}
{% fill "header-{{ active_header_name }}" data="data" %}
<b>{{ data.value }}</b>
{% endfill %}
{% endcomponent %}
```
> NOTE: It's better to use static slot names whenever possible for clarity. The dynamic slot names should be reserved for advanced use only.
Lastly, in rare cases, you can also pass the slot name via [the spread operator](#spread-operator). This is possible, because the slot name argument is actually a shortcut for a `name` keyword argument.
So this:
```django
{% slot "content" / %}
```
is the same as:
```django
{% slot name="content" / %}
```
So it's possible to define a `name` key on a dictionary, and then spread that onto the slot tag:
```django
{# slot_props = {"name": "content"} #}
{% slot ...slot_props / %}
```
## Accessing data passed to the component
When you call `Component.render` or `Component.render_to_response`, the inputs to these methods can be accessed from within the instance under `self.input`.
@ -1862,7 +1922,23 @@ attributes_to_string(attrs)
## Template tag syntax
All template tags in django_component, like `{% component %}` or `{% slot %}`, and so on,
support extra syntax that makes it possible to write components like in Vue or React.
support extra syntax that makes it possible to write components like in Vue or React (JSX).
### Self-closing tags
When you have a tag like `{% component %}` or `{% slot %}`, but it has no content, you can simply append a forward slash `/` at the end, instead of writing out the closing tags like `{% endcomponent %}` or `{% endslot %}`:
So this:
```django
{% component "button" %}{% endcomponent %}
```
becomes
```django
{% component "button" / %}
```
### Special characters
@ -1960,6 +2036,7 @@ Instead, template tags in django_components (`{% component %}`, `{% slot %}`, `{
"As positional arg {# yay #}"
title="{{ person.first_name }} {{ person.last_name }}"
id="{% random_int 10 20 %}"
readonly="{{ editable|not }}"
author="John Wick {# TODO: parametrize #}"
/ %}
```
@ -1968,6 +2045,7 @@ In the example above:
- Component `test` receives a positional argument with value `"As positional arg "`. The comment is omitted.
- Kwarg `title` is passed as a string, e.g. `John Doe`
- Kwarg `id` is passed as `int`, e.g. `15`
- Kwarg `readonly` is passed as `bool`, e.g. `False`
- Kwarg `author` is passed as a string, e.g. `John Wick ` (Comment omitted)
This is inspired by [django-cotton](https://github.com/wrabit/django-cotton#template-expressions-in-attributes).
@ -2028,7 +2106,7 @@ Similar is possible with [`django-expr`](https://pypi.org/project/django-expr/),
/ %}
```
> Note: Never use this feature to mix business logic and template logic. Business logic should still be in the template!
> Note: Never use this feature to mix business logic and template logic. Business logic should still be in the view!
### Pass dictonary by its key-value pairs
@ -2147,10 +2225,7 @@ First we use the `{% provide %}` tag to define the data we want to "provide" (ma
Notice that the `provide` tag REQUIRES a name as a first argument. This is the _key_ by which we can then access the data passed to this tag.
`provide` tag _key_, similarly to the _name_ argument in `component` or `slot` tags, has these requirements:
- The _key_ must be a string literal
- It must be a valid identifier (AKA a valid Python variable name)
`provide` tag name must resolve to a valid identifier (AKA a valid Python variable name).
Once you've set the name, you define the data you want to "provide" by passing it as keyword arguments. This is similar to how you pass data to the `{% with %}` tag.
@ -2163,6 +2238,15 @@ Once you've set the name, you define the data you want to "provide" by passing i
> {% endprovide %}
> ```
Similarly to [slots and fills](#dynamic-slots-and-fills), also provide's name argument can be set dynamically via a variable, a template expression, or a spread operator:
```django
{% provide name=name ... %}
...
{% provide %}
</table>
```
### Using `inject()` method
To "inject" (access) the data defined on the `provide` tag, you can use the `inject()` method inside of `get_context_data()`.
@ -2180,7 +2264,7 @@ class ChildComponent(Component):
return {}
```
First argument to `inject` is the _key_ of the provided data. This
First argument to `inject` is the _key_ (or _name_) of the provided data. This
must match the string that you used in the `provide` tag. If no provider
with given key is found, `inject` raises a `KeyError`.
@ -2237,7 +2321,7 @@ With this in mind, the `{% component %}` tag behaves similarly to `{% include %}
And just like with `{% include %}`, if you don't want a specific component template to have access to the parent context, add `only` to the `{% component %}` tag:
```htmldjango
{% component "calendar" date="2015-06-19" only %}{% endcomponent %}
{% component "calendar" date="2015-06-19" only / %}
```
NOTE: `{% csrf_token %}` tags need access to the top-level context, and they will not function properly if they are rendered in a component that is called with the `only` modifier.

View file

@ -25,7 +25,6 @@ from django.forms.widgets import Media
from django.http import HttpRequest, HttpResponse
from django.template.base import NodeList, Template, TextNode
from django.template.context import Context
from django.template.exceptions import TemplateSyntaxError
from django.template.loader import get_template
from django.template.loader_tags import BLOCK_CONTEXT_KEY
from django.utils.html import conditional_escape
@ -48,8 +47,6 @@ from django_components.middleware import is_dependency_middleware_active
from django_components.node import BaseNode
from django_components.slots import (
DEFAULT_SLOT_KEY,
SLOT_DATA_KWARG,
SLOT_DEFAULT_KWARG,
FillContent,
FillNode,
SlotContent,
@ -57,6 +54,7 @@ from django_components.slots import (
SlotRef,
SlotResult,
_nodelist_to_slot_render_func,
resolve_fill_nodes,
resolve_slots,
)
from django_components.utils import gen_id
@ -634,23 +632,7 @@ class ComponentNode(BaseNode):
),
}
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.resolve(context)
if resolved_name in fill_content:
raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: "
f"Detected duplicate fill tag name '{resolved_name}'."
)
fill_kwargs = fill_node.resolve_kwargs(context, self.name)
fill_content[resolved_name] = FillContent(
content_func=_nodelist_to_slot_render_func(fill_node.nodelist),
slot_default_var=fill_kwargs[SLOT_DEFAULT_KWARG],
slot_data_var=fill_kwargs[SLOT_DATA_KWARG],
)
fill_content = resolve_fill_nodes(context, self.fill_nodes, self.name)
component: Component = component_cls(
registered_name=self.name,

View file

@ -1,4 +1,4 @@
from typing import Optional
from typing import Dict, Optional, Tuple
from django.template import Context
from django.template.base import NodeList
@ -10,6 +10,8 @@ from django_components.logger import trace_msg
from django_components.node import BaseNode
from django_components.utils import gen_id
PROVIDE_NAME_KWARG = "name"
class ProvideNode(BaseNode):
"""
@ -19,34 +21,43 @@ class ProvideNode(BaseNode):
def __init__(
self,
name: str,
nodelist: NodeList,
trace_id: str,
node_id: Optional[str] = None,
kwargs: Optional[RuntimeKwargs] = None,
):
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
self.name = name
self.nodelist = nodelist
self.node_id = node_id or gen_id()
self.trace_id = trace_id
self.kwargs = kwargs or RuntimeKwargs({})
def __repr__(self) -> str:
return f"<Provide Node: {self.name}. Contents: {repr(self.nodelist)}. Data: {self.provide_kwargs.kwargs}>"
return f"<Provide Node: {self.node_id}. Contents: {repr(self.nodelist)}>"
def render(self, context: Context) -> SafeString:
trace_msg("RENDR", "PROVIDE", self.name, self.node_id)
trace_msg("RENDR", "PROVIDE", self.trace_id, self.node_id)
kwargs = self.kwargs.resolve(context)
name, kwargs = self.resolve_kwargs(context)
# NOTE: The "provided" kwargs are meant to be shared privately, meaning that components
# have to explicitly opt in by using the `Component.inject()` method. That's why we don't
# add the provided kwargs into the Context.
with context.update({}):
# "Provide" the data to child nodes
set_provided_context_var(context, self.name, kwargs)
set_provided_context_var(context, name, kwargs)
output = self.nodelist.render(context)
trace_msg("RENDR", "PROVIDE", self.name, self.node_id, msg="...Done!")
trace_msg("RENDR", "PROVIDE", self.trace_id, self.node_id, msg="...Done!")
return output
def resolve_kwargs(self, context: Context) -> Tuple[str, Dict[str, Optional[str]]]:
kwargs = self.kwargs.resolve(context)
name = kwargs.pop(PROVIDE_NAME_KWARG, None)
if not name:
raise RuntimeError("Provide tag kwarg 'name' is missing")
return (name, kwargs)

View file

@ -25,6 +25,7 @@ TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True)
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
SLOT_DATA_KWARG = "data"
SLOT_NAME_KWARG = "name"
SLOT_DEFAULT_KWARG = "default"
SLOT_REQUIRED_KEYWORD = "required"
SLOT_DEFAULT_KEYWORD = "default"
@ -127,8 +128,8 @@ class SlotRef:
class SlotNode(BaseNode):
def __init__(
self,
name: str,
nodelist: NodeList,
trace_id: str,
node_id: Optional[str] = None,
kwargs: Optional[RuntimeKwargs] = None,
is_required: bool = False,
@ -136,9 +137,9 @@ class SlotNode(BaseNode):
):
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
self.name = name
self.is_required = is_required
self.is_default = is_default
self.trace_id = trace_id
@property
def active_flags(self) -> List[str]:
@ -150,14 +151,16 @@ class SlotNode(BaseNode):
return flags
def __repr__(self) -> str:
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
return f"<Slot Node: {self.node_id}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
def render(self, context: Context) -> SafeString:
trace_msg("RENDR", "SLOT", self.name, self.node_id)
trace_msg("RENDR", "SLOT", self.trace_id, self.node_id)
slots: Dict[SlotId, "SlotFill"] = context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY]
# NOTE: Slot entry MUST be present. If it's missing, there was an issue upstream.
slot_fill = slots[self.node_id]
name, kwargs = self.resolve_kwargs(context)
extra_context: Dict[str, Any] = {}
# Irrespective of which context we use ("root" context or the one passed to this
@ -177,19 +180,18 @@ class SlotNode(BaseNode):
if default_var:
if not default_var.isidentifier():
raise TemplateSyntaxError(
f"Slot default alias in fill '{self.name}' must be a valid identifier. Got '{default_var}'"
f"Slot default alias in fill '{name}' must be a valid identifier. Got '{default_var}'"
)
extra_context[default_var] = slot_ref
# Expose the kwargs that were passed to the `{% slot %}` tag. These kwargs
# are made available through a variable name that was set on the `{% fill %}`
# tag.
kwargs = self.kwargs.resolve(context)
data_var = slot_fill.slot_data_var
if data_var:
if not data_var.isidentifier():
raise TemplateSyntaxError(
f"Slot data alias in fill '{self.name}' must be a valid identifier. Got '{data_var}'"
f"Slot data alias in fill '{name}' must be a valid identifier. Got '{data_var}'"
)
extra_context[data_var] = kwargs
@ -202,7 +204,7 @@ class SlotNode(BaseNode):
# the render function ALWAYS receives them.
output = slot_fill.content_func(used_ctx, kwargs, slot_ref)
trace_msg("RENDR", "SLOT", self.name, self.node_id, msg="...Done!")
trace_msg("RENDR", "SLOT", self.trace_id, self.node_id, msg="...Done!")
return output
def _resolve_slot_context(self, context: Context, slot_fill: "SlotFill") -> Context:
@ -220,6 +222,19 @@ class SlotNode(BaseNode):
else:
raise ValueError(f"Unknown value for CONTEXT_BEHAVIOR: '{app_settings.CONTEXT_BEHAVIOR}'")
def resolve_kwargs(
self,
context: Context,
component_name: Optional[str] = None,
) -> Tuple[str, Dict[str, Optional[str]]]:
kwargs = self.kwargs.resolve(context)
name = kwargs.pop(SLOT_NAME_KWARG, None)
if not name:
raise RuntimeError(f"Slot tag kwarg 'name' is missing in component {component_name}")
return (name, kwargs)
class FillNode(BaseNode):
"""
@ -230,16 +245,16 @@ class FillNode(BaseNode):
def __init__(
self,
name: FilterExpression,
nodelist: NodeList,
kwargs: RuntimeKwargs,
trace_id: str,
node_id: Optional[str] = None,
is_implicit: bool = False,
):
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
self.name = name
self.is_implicit = is_implicit
self.trace_id = trace_id
self.component_id: Optional[str] = None
def render(self, context: Context) -> str:
@ -250,22 +265,24 @@ class FillNode(BaseNode):
)
def __repr__(self) -> str:
return f"<{type(self)} Name: {self.name}. Contents: {repr(self.nodelist)}.>"
return f"<{type(self)} ID: {self.node_id}. Contents: {repr(self.nodelist)}.>"
def resolve_kwargs(self, context: Context, component_name: Optional[str] = None) -> Dict[str, Optional[str]]:
kwargs = self.kwargs.resolve(context)
name = self._resolve_kwarg(kwargs, SLOT_NAME_KWARG, "slot name", component_name, identifier=False)
default_key = self._resolve_kwarg(kwargs, SLOT_DEFAULT_KWARG, "slot default", component_name)
data_key = self._resolve_kwarg(kwargs, SLOT_DATA_KWARG, "slot data", component_name)
# data and default cannot be bound to the same variable
if data_key and default_key and data_key == default_key:
raise RuntimeError(
f"Fill {self.name} received the same string for slot default ({SLOT_DEFAULT_KWARG}=...)"
f"Fill '{name}' received the same string for slot default ({SLOT_DEFAULT_KWARG}=...)"
f" and slot data ({SLOT_DATA_KWARG}=...)"
)
return {
SLOT_NAME_KWARG: name,
SLOT_DEFAULT_KWARG: default_key,
SLOT_DATA_KWARG: data_key,
}
@ -276,16 +293,19 @@ class FillNode(BaseNode):
key: str,
name: str,
component_name: Optional[str] = None,
identifier: bool = True,
) -> Optional[str]:
if key not in kwargs:
return None
value = kwargs[key]
if not is_identifier(value):
if identifier and not is_identifier(value):
raise RuntimeError(
f"Fill tag {name} in component {component_name}"
f"does not resolve to a valid Python identifier, got '{value}'"
)
elif not identifier and not value:
raise RuntimeError(f"Fill tag {name} is missing value in component {component_name}")
return value
@ -340,15 +360,15 @@ def _try_parse_as_named_fill_tag_set(
seen_names: Set[str] = set()
for node in nodelist:
if isinstance(node, FillNode):
# Check that, after we've resolved the names, that there's still no duplicates.
# This makes sure that if two different variables refer to same string, we detect
# them.
if node.name.token in seen_names:
raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: "
f"Detected duplicate fill tag name '{node.name}'."
)
seen_names.add(node.name.token)
# If the fill name was defined statically, then check for no duplicates.
maybe_fill_name = node.kwargs.kwargs.get(SLOT_NAME_KWARG)
if isinstance(maybe_fill_name, FilterExpression):
if maybe_fill_name.token in seen_names:
raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: "
f"Detected duplicate fill tag name '{maybe_fill_name.token}'."
)
seen_names.add(maybe_fill_name.token)
result.append(node)
elif isinstance(node, CommentNode):
pass
@ -378,13 +398,50 @@ def _try_parse_as_default_fill(
return [
FillNode(
nodelist=nodelist,
name=FilterExpression(json.dumps(DEFAULT_SLOT_KEY), Parser("")),
kwargs=RuntimeKwargs({}),
kwargs=RuntimeKwargs(
{
# Wrap the default slot name in quotes so it's treated as FilterExpression
SLOT_NAME_KWARG: FilterExpression(json.dumps(DEFAULT_SLOT_KEY), Parser("")),
}
),
is_implicit=True,
trace_id="default",
)
]
def resolve_fill_nodes(
context: Context,
fill_nodes: List[FillNode],
component_name: str,
) -> Dict[str, FillContent]:
fill_content: Dict[str, FillContent] = {}
for fill_node in fill_nodes:
# Note that outer component context is used to resolve variables in
# fill tag.
fill_kwargs = fill_node.resolve_kwargs(context, component_name)
fill_name = fill_kwargs.get(SLOT_NAME_KWARG)
if SLOT_NAME_KWARG not in fill_kwargs:
raise TemplateSyntaxError("Fill tag is missing the 'name' kwarg")
if not isinstance(fill_name, str):
raise TemplateSyntaxError(f"Fill tag 'name' kwarg must resolve to a string, got {fill_name}")
if fill_name in fill_content:
raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: "
f"Detected duplicate fill tag name '{fill_name}'."
)
fill_content[fill_name] = FillContent(
content_func=_nodelist_to_slot_render_func(fill_node.nodelist),
slot_default_var=fill_kwargs[SLOT_DEFAULT_KWARG],
slot_data_var=fill_kwargs[SLOT_DATA_KWARG],
)
return fill_content
####################
# SLOT RESOLUTION
####################
@ -427,13 +484,15 @@ def resolve_slots(
if not isinstance(node, SlotNode):
return
slot_name, _ = node.resolve_kwargs(context, component_name)
# 1. Collect slots
# Basically we take all the important info form the SlotNode, so the logic is
# less coupled to Django's Template/Node. Plain tuples should also help with
# troubleshooting.
slot = Slot(
id=node.node_id,
name=node.name,
name=slot_name,
nodelist=node.nodelist,
is_default=node.is_default,
is_required=node.is_required,

View file

@ -1,7 +1,7 @@
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Union
import django.template
from django.template.base import FilterExpression, NodeList, Parser, Token, TokenType
from django.template.base import NodeList, Parser, Token, TokenType
from django.template.exceptions import TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe
from django.utils.text import smart_split
@ -25,7 +25,6 @@ from django_components.expression import (
is_internal_spread_operator,
is_kwarg,
is_spread_operator,
resolve_string,
)
from django_components.logger import trace_msg
from django_components.middleware import (
@ -33,11 +32,12 @@ from django_components.middleware import (
JS_DEPENDENCY_PLACEHOLDER,
is_dependency_middleware_active,
)
from django_components.provide import ProvideNode
from django_components.provide import PROVIDE_NAME_KWARG, ProvideNode
from django_components.slots import (
SLOT_DATA_KWARG,
SLOT_DEFAULT_KEYWORD,
SLOT_DEFAULT_KWARG,
SLOT_NAME_KWARG,
SLOT_REQUIRED_KEYWORD,
FillNode,
SlotNode,
@ -45,7 +45,7 @@ from django_components.slots import (
)
from django_components.tag_formatter import get_tag_formatter
from django_components.template_parser import parse_bits
from django_components.utils import gen_id, is_str_wrapped_in_quotes
from django_components.utils import gen_id
if TYPE_CHECKING:
from django_components.component import Component
@ -139,27 +139,30 @@ def slot(parser: Parser, token: Token) -> SlotNode:
"slot",
parser,
token,
params=["name"],
params=[SLOT_NAME_KWARG],
optional_params=[SLOT_NAME_KWARG],
flags=[SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD],
keywordonly_kwargs=True,
repeatable_kwargs=False,
end_tag="endslot",
)
data = _parse_slot_args(parser, tag)
trace_msg("PARSE", "SLOT", data.name, tag.id)
slot_name_kwarg = tag.kwargs.kwargs.get(SLOT_NAME_KWARG, None)
trace_id = f"slot-id-{tag.id} ({slot_name_kwarg})" if slot_name_kwarg else f"slot-id-{tag.id}"
trace_msg("PARSE", "SLOT", trace_id, tag.id)
body = tag.parse_body()
slot_node = SlotNode(
name=data.name,
nodelist=body,
node_id=tag.id,
kwargs=tag.kwargs,
is_required=tag.flags[SLOT_REQUIRED_KEYWORD],
is_default=tag.flags[SLOT_DEFAULT_KEYWORD],
trace_id=trace_id,
)
trace_msg("PARSE", "SLOT", data.name, tag.id, "...Done!")
trace_msg("PARSE", "SLOT", trace_id, tag.id, "...Done!")
return slot_node
@ -177,24 +180,27 @@ def fill(parser: Parser, token: Token) -> FillNode:
"fill",
parser,
token,
params=["name"],
params=[SLOT_NAME_KWARG],
optional_params=[SLOT_NAME_KWARG],
keywordonly_kwargs=[SLOT_DATA_KWARG, SLOT_DEFAULT_KWARG],
repeatable_kwargs=False,
end_tag="endfill",
)
slot_name = tag.named_args["name"]
trace_msg("PARSE", "FILL", str(slot_name), tag.id)
fill_name_kwarg = tag.kwargs.kwargs.get(SLOT_NAME_KWARG, None)
trace_id = f"fill-id-{tag.id} ({fill_name_kwarg})" if fill_name_kwarg else f"fill-id-{tag.id}"
trace_msg("PARSE", "FILL", trace_id, tag.id)
body = tag.parse_body()
fill_node = FillNode(
nodelist=body,
name=slot_name,
node_id=tag.id,
kwargs=tag.kwargs,
trace_id=trace_id,
)
trace_msg("PARSE", "FILL", str(slot_name), tag.id, "...Done!")
trace_msg("PARSE", "FILL", trace_id, tag.id, "...Done!")
return fill_node
@ -244,7 +250,7 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
# Tag all fill nodes as children of this particular component instance
for node in fill_nodes:
trace_msg("ASSOC", "FILL", node.name, node.node_id, component_id=tag.id)
trace_msg("ASSOC", "FILL", node.trace_id, node.node_id, component_id=tag.id)
node.component_id = tag.id
component_node = ComponentNode(
@ -267,25 +273,28 @@ def provide(parser: Parser, token: Token) -> ProvideNode:
"provide",
parser,
token,
params=["name"],
params=[PROVIDE_NAME_KWARG],
optional_params=[PROVIDE_NAME_KWARG],
flags=[],
keywordonly_kwargs=True,
repeatable_kwargs=False,
end_tag="endprovide",
)
data = _parse_provide_args(parser, tag)
trace_msg("PARSE", "PROVIDE", data.key, tag.id)
name_kwarg = tag.kwargs.kwargs.get(PROVIDE_NAME_KWARG, None)
trace_id = f"provide-id-{tag.id} ({name_kwarg})" if name_kwarg else f"fill-id-{tag.id}"
trace_msg("PARSE", "PROVIDE", trace_id, tag.id)
body = tag.parse_body()
slot_node = ProvideNode(
name=data.key,
nodelist=body,
node_id=tag.id,
kwargs=tag.kwargs,
trace_id=trace_id,
)
trace_msg("PARSE", "PROVIDE", data.key, tag.id, "...Done!")
trace_msg("PARSE", "PROVIDE", trace_id, tag.id, "...Done!")
return slot_node
@ -531,7 +540,9 @@ def _parse_tag(
else:
is_key_allowed = keywordonly_kwargs == True or key in keywordonly_kwargs # noqa: E712
if not is_key_allowed:
extra_keywords.add(key)
is_optional = key in optional_params if optional_params else False
if not is_optional:
extra_keywords.add(key)
# Check for repeated keys
if key in kwargs:
@ -674,43 +685,3 @@ def _fix_nested_tags(parser: Parser, block_token: Token) -> None:
expects_text = True
continue
class ParsedSlotTag(NamedTuple):
name: str
def _parse_slot_args(
parser: Parser,
tag: ParsedTag,
) -> ParsedSlotTag:
slot_name_expr = tag.named_args["name"]
if not isinstance(slot_name_expr, FilterExpression):
raise TemplateSyntaxError(f"Slot name must be string literal, got {slot_name_expr}")
slot_name = slot_name_expr.token
if not is_str_wrapped_in_quotes(slot_name):
raise TemplateSyntaxError(f"'{tag.name}' name must be a string 'literal', got {slot_name}.")
slot_name = resolve_string(slot_name, parser)
return ParsedSlotTag(name=slot_name)
class ParsedProvideTag(NamedTuple):
key: str
def _parse_provide_args(
parser: Parser,
tag: ParsedTag,
) -> ParsedProvideTag:
provide_key_expr = tag.named_args["name"]
if not isinstance(provide_key_expr, FilterExpression):
raise TemplateSyntaxError(f"Provide key must be string literal, got {provide_key_expr}")
provide_key = provide_key_expr.token
if not is_str_wrapped_in_quotes(provide_key):
raise TemplateSyntaxError(f"'{tag.name}' key must be a string 'literal', got {provide_key}")
provide_key = resolve_string(provide_key, parser)
return ParsedProvideTag(key=provide_key)

View file

@ -188,6 +188,38 @@ class HtmlAttrsTests(BaseTestCase):
)
self.assertNotIn("override-me", rendered)
def test_tag_spread(self):
@register("test")
class AttrsComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div {% html_attrs ...props class="another-class" %}>
content
</div>
""" # noqa: E501
def get_context_data(self, *args, attrs):
return {
"props": {
"attrs": attrs,
"defaults": {"class": "override-me"},
"class": "added_class",
"data-id": 123,
},
}
template = Template(self.template_str)
rendered = template.render(Context({"class_var": "padding-top-8"}))
self.assertHTMLEqual(
rendered,
"""
<div @click.stop="dispatch('click_event')" class="added_class another-class padding-top-8" data-id="123" x-data="{hello: 'world'}">
content
</div>
""", # noqa: E501
)
self.assertNotIn("override-me", rendered)
def test_tag_aggregate_args(self):
@register("test")
class AttrsComponent(Component):

View file

@ -220,7 +220,7 @@ class ProvideTemplateTagTest(BaseTestCase):
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_key_single_quotes(self):
def test_provide_name_single_quotes(self):
@register("injectee")
class InjectComponent(Component):
template: types.django_html = """
@ -252,7 +252,87 @@ class ProvideTemplateTagTest(BaseTestCase):
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_no_key_raises(self):
def test_provide_name_as_var(self):
@register("injectee")
class InjectComponent(Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("my_provide", "default")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide var_a key="hi" another=123 %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
{% component "injectee" %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(
Context(
{
"var_a": "my_provide",
}
)
)
self.assertHTMLEqual(
rendered,
"""
<div> injected: DepInject(key='hi', another=123) </div>
<div> injected: default </div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_name_as_spread(self):
@register("injectee")
class InjectComponent(Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("my_provide", "default")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide ...provide_props %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
{% component "injectee" %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(
Context(
{
"provide_props": {
"name": "my_provide",
"key": "hi",
"another": 123,
},
}
)
)
self.assertHTMLEqual(
rendered,
"""
<div> injected: DepInject(key='hi', another=123) </div>
<div> injected: default </div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_no_name_raises(self):
@register("injectee")
class InjectComponent(Component):
template: types.django_html = """
@ -272,11 +352,11 @@ class ProvideTemplateTagTest(BaseTestCase):
{% component "injectee" %}
{% endcomponent %}
"""
with self.assertRaises(TemplateSyntaxError):
Template(template_str)
with self.assertRaisesMessage(RuntimeError, "Provide tag kwarg 'name' is missing"):
Template(template_str).render(Context({}))
@parametrize_context_behavior(["django", "isolated"])
def test_provide_key_must_be_string_literal(self):
def test_provide_name_must_be_string_literal(self):
@register("injectee")
class InjectComponent(Component):
template: types.django_html = """
@ -296,11 +376,11 @@ class ProvideTemplateTagTest(BaseTestCase):
{% component "injectee" %}
{% endcomponent %}
"""
with self.assertRaises(TemplateSyntaxError):
Template(template_str)
with self.assertRaisesMessage(RuntimeError, "Provide tag kwarg 'name' is missing"):
Template(template_str).render(Context({}))
@parametrize_context_behavior(["django", "isolated"])
def test_provide_key_must_be_identifier(self):
def test_provide_name_must_be_identifier(self):
@register("injectee")
class InjectComponent(Component):
template: types.django_html = """

View file

@ -796,6 +796,80 @@ class ScopedSlotTest(BaseTestCase):
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_slot_data_with_variable(self):
@register("test")
class TestComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div>
{% slot slot_name abc=abc var123=var123 default required %}Default text{% endslot %}
</div>
"""
def get_context_data(self):
return {
"slot_name": "my_slot",
"abc": "def",
"var123": 456,
}
template: types.django_html = """
{% load component_tags %}
{% component "test" %}
{% fill "my_slot" data="slot_data_in_fill" %}
{{ slot_data_in_fill.abc }}
{{ slot_data_in_fill.var123 }}
{% endfill %}
{% endcomponent %}
"""
rendered = Template(template).render(Context())
expected = """
<div>
def
456
</div>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_slot_data_with_spread(self):
@register("test")
class TestComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div>
{% slot ...slot_props default required %}Default text{% endslot %}
</div>
"""
def get_context_data(self):
return {
"slot_props": {
"name": "my_slot",
"abc": "def",
"var123": 456,
},
}
template: types.django_html = """
{% load component_tags %}
{% component "test" %}
{% fill "my_slot" data="slot_data_in_fill" %}
{{ slot_data_in_fill.abc }}
{{ slot_data_in_fill.var123 }}
{% endfill %}
{% endcomponent %}
"""
rendered = Template(template).render(Context())
expected = """
<div>
def
456
</div>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_slot_data_raises_on_slot_data_and_slot_default_same_var(self):
@register("test")
@ -823,7 +897,7 @@ class ScopedSlotTest(BaseTestCase):
"""
with self.assertRaisesMessage(
RuntimeError,
'Fill "my_slot" received the same string for slot default (default=...) and slot data (data=...)',
"Fill 'my_slot' received the same string for slot default (default=...) and slot data (data=...)",
):
Template(template).render(Context())
@ -905,6 +979,94 @@ class ScopedSlotTest(BaseTestCase):
expected = "<div> Default text </div>"
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_slot_data_fill_with_variables(self):
@register("test")
class TestComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div>
{% slot "my_slot" abc=abc var123=var123 %}Default text{% endslot %}
</div>
"""
def get_context_data(self):
return {
"abc": "def",
"var123": 456,
}
template: types.django_html = """
{% load component_tags %}
{% component "test" %}
{% fill fill_name data=data_var %}
{{ slot_data_in_fill.abc }}
{{ slot_data_in_fill.var123 }}
{% endfill %}
{% endcomponent %}
"""
rendered = Template(template).render(
Context(
{
"fill_name": "my_slot",
"data_var": "slot_data_in_fill",
}
)
)
expected = """
<div>
def
456
</div>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_slot_data_fill_with_spread(self):
@register("test")
class TestComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div>
{% slot "my_slot" abc=abc var123=var123 %}Default text{% endslot %}
</div>
"""
def get_context_data(self):
return {
"abc": "def",
"var123": 456,
}
template: types.django_html = """
{% load component_tags %}
{% component "test" %}
{% fill ...fill_props %}
{{ slot_data_in_fill.abc }}
{{ slot_data_in_fill.var123 }}
{% endfill %}
{% endcomponent %}
"""
rendered = Template(template).render(
Context(
{
"fill_props": {
"name": "my_slot",
"data": "slot_data_in_fill",
},
}
)
)
expected = """
<div>
def
456
</div>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_nested_fills(self):
@register("test")