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) - [Use components as views](#use-components-as-views)
- [Autodiscovery](#autodiscovery) - [Autodiscovery](#autodiscovery)
- [Using slots in templates](#using-slots-in-templates) - [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) - [Rendering HTML attributes](#rendering-html-attributes)
- [Template tag syntax](#template-tag-syntax) - [Template tag syntax](#template-tag-syntax)
- [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject) - [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** **Version 0.93**
- Spread operator `...dict` inside template tags. See [Spread operator](#spread-operator)) - 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)) - 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** 🚨📢 **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)) - 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: If you want to define your own `View` class, you need to:
1. Set the class as `Component.View` 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()`. In the example below, we added extra logic into `View.setup()`.
@ -1440,18 +1441,77 @@ to the same name. This raises an error:
{% endcomponent %} {% 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 ```django
<body> {% slot "content" / %}
{% component "calendar" date="2015-06-19" %}
{% endcomponent %}
</body>
``` ```
### 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`. 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 ## Template tag syntax
All template tags in django_component, like `{% component %}` or `{% slot %}`, and so on, 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 ### Special characters
@ -1960,6 +2036,7 @@ Instead, template tags in django_components (`{% component %}`, `{% slot %}`, `{
"As positional arg {# yay #}" "As positional arg {# yay #}"
title="{{ person.first_name }} {{ person.last_name }}" title="{{ person.first_name }} {{ person.last_name }}"
id="{% random_int 10 20 %}" id="{% random_int 10 20 %}"
readonly="{{ editable|not }}"
author="John Wick {# TODO: parametrize #}" 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. - 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 `title` is passed as a string, e.g. `John Doe`
- Kwarg `id` is passed as `int`, e.g. `15` - 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) - 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). 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 ### 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. 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: `provide` tag name must resolve to a valid identifier (AKA a valid Python variable name).
- The _key_ must be a string literal
- It must be 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. 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 %} > {% 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 ### Using `inject()` method
To "inject" (access) the data defined on the `provide` tag, you can use the `inject()` method inside of `get_context_data()`. 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 {} 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 must match the string that you used in the `provide` tag. If no provider
with given key is found, `inject` raises a `KeyError`. 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: 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 ```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. 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.http import HttpRequest, HttpResponse
from django.template.base import NodeList, Template, TextNode from django.template.base import NodeList, Template, TextNode
from django.template.context import Context from django.template.context import Context
from django.template.exceptions import TemplateSyntaxError
from django.template.loader import get_template from django.template.loader import get_template
from django.template.loader_tags import BLOCK_CONTEXT_KEY from django.template.loader_tags import BLOCK_CONTEXT_KEY
from django.utils.html import conditional_escape 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.node import BaseNode
from django_components.slots import ( from django_components.slots import (
DEFAULT_SLOT_KEY, DEFAULT_SLOT_KEY,
SLOT_DATA_KWARG,
SLOT_DEFAULT_KWARG,
FillContent, FillContent,
FillNode, FillNode,
SlotContent, SlotContent,
@ -57,6 +54,7 @@ from django_components.slots import (
SlotRef, SlotRef,
SlotResult, SlotResult,
_nodelist_to_slot_render_func, _nodelist_to_slot_render_func,
resolve_fill_nodes,
resolve_slots, resolve_slots,
) )
from django_components.utils import gen_id from django_components.utils import gen_id
@ -634,23 +632,7 @@ class ComponentNode(BaseNode):
), ),
} }
else: else:
fill_content = {} fill_content = resolve_fill_nodes(context, self.fill_nodes, self.name)
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],
)
component: Component = component_cls( component: Component = component_cls(
registered_name=self.name, 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 import Context
from django.template.base import NodeList 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.node import BaseNode
from django_components.utils import gen_id from django_components.utils import gen_id
PROVIDE_NAME_KWARG = "name"
class ProvideNode(BaseNode): class ProvideNode(BaseNode):
""" """
@ -19,34 +21,43 @@ class ProvideNode(BaseNode):
def __init__( def __init__(
self, self,
name: str,
nodelist: NodeList, nodelist: NodeList,
trace_id: str,
node_id: Optional[str] = None, node_id: Optional[str] = None,
kwargs: Optional[RuntimeKwargs] = None, kwargs: Optional[RuntimeKwargs] = None,
): ):
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id) super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
self.name = name
self.nodelist = nodelist self.nodelist = nodelist
self.node_id = node_id or gen_id() self.node_id = node_id or gen_id()
self.trace_id = trace_id
self.kwargs = kwargs or RuntimeKwargs({}) self.kwargs = kwargs or RuntimeKwargs({})
def __repr__(self) -> str: 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: 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 # 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 # have to explicitly opt in by using the `Component.inject()` method. That's why we don't
# add the provided kwargs into the Context. # add the provided kwargs into the Context.
with context.update({}): with context.update({}):
# "Provide" the data to child nodes # "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) 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 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" DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
SLOT_DATA_KWARG = "data" SLOT_DATA_KWARG = "data"
SLOT_NAME_KWARG = "name"
SLOT_DEFAULT_KWARG = "default" SLOT_DEFAULT_KWARG = "default"
SLOT_REQUIRED_KEYWORD = "required" SLOT_REQUIRED_KEYWORD = "required"
SLOT_DEFAULT_KEYWORD = "default" SLOT_DEFAULT_KEYWORD = "default"
@ -127,8 +128,8 @@ class SlotRef:
class SlotNode(BaseNode): class SlotNode(BaseNode):
def __init__( def __init__(
self, self,
name: str,
nodelist: NodeList, nodelist: NodeList,
trace_id: str,
node_id: Optional[str] = None, node_id: Optional[str] = None,
kwargs: Optional[RuntimeKwargs] = None, kwargs: Optional[RuntimeKwargs] = None,
is_required: bool = False, is_required: bool = False,
@ -136,9 +137,9 @@ class SlotNode(BaseNode):
): ):
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id) super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
self.name = name
self.is_required = is_required self.is_required = is_required
self.is_default = is_default self.is_default = is_default
self.trace_id = trace_id
@property @property
def active_flags(self) -> List[str]: def active_flags(self) -> List[str]:
@ -150,14 +151,16 @@ class SlotNode(BaseNode):
return flags return flags
def __repr__(self) -> str: 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: 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] 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. # NOTE: Slot entry MUST be present. If it's missing, there was an issue upstream.
slot_fill = slots[self.node_id] slot_fill = slots[self.node_id]
name, kwargs = self.resolve_kwargs(context)
extra_context: Dict[str, Any] = {} extra_context: Dict[str, Any] = {}
# Irrespective of which context we use ("root" context or the one passed to this # 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 default_var:
if not default_var.isidentifier(): if not default_var.isidentifier():
raise TemplateSyntaxError( 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 extra_context[default_var] = slot_ref
# Expose the kwargs that were passed to the `{% slot %}` tag. These kwargs # 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 %}` # are made available through a variable name that was set on the `{% fill %}`
# tag. # tag.
kwargs = self.kwargs.resolve(context)
data_var = slot_fill.slot_data_var data_var = slot_fill.slot_data_var
if data_var: if data_var:
if not data_var.isidentifier(): if not data_var.isidentifier():
raise TemplateSyntaxError( 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 extra_context[data_var] = kwargs
@ -202,7 +204,7 @@ class SlotNode(BaseNode):
# the render function ALWAYS receives them. # the render function ALWAYS receives them.
output = slot_fill.content_func(used_ctx, kwargs, slot_ref) 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 return output
def _resolve_slot_context(self, context: Context, slot_fill: "SlotFill") -> Context: def _resolve_slot_context(self, context: Context, slot_fill: "SlotFill") -> Context:
@ -220,6 +222,19 @@ class SlotNode(BaseNode):
else: else:
raise ValueError(f"Unknown value for CONTEXT_BEHAVIOR: '{app_settings.CONTEXT_BEHAVIOR}'") 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): class FillNode(BaseNode):
""" """
@ -230,16 +245,16 @@ class FillNode(BaseNode):
def __init__( def __init__(
self, self,
name: FilterExpression,
nodelist: NodeList, nodelist: NodeList,
kwargs: RuntimeKwargs, kwargs: RuntimeKwargs,
trace_id: str,
node_id: Optional[str] = None, node_id: Optional[str] = None,
is_implicit: bool = False, is_implicit: bool = False,
): ):
super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id) super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
self.name = name
self.is_implicit = is_implicit self.is_implicit = is_implicit
self.trace_id = trace_id
self.component_id: Optional[str] = None self.component_id: Optional[str] = None
def render(self, context: Context) -> str: def render(self, context: Context) -> str:
@ -250,22 +265,24 @@ class FillNode(BaseNode):
) )
def __repr__(self) -> str: 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]]: def resolve_kwargs(self, context: Context, component_name: Optional[str] = None) -> Dict[str, Optional[str]]:
kwargs = self.kwargs.resolve(context) 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) 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_key = self._resolve_kwarg(kwargs, SLOT_DATA_KWARG, "slot data", component_name)
# data and default cannot be bound to the same variable # data and default cannot be bound to the same variable
if data_key and default_key and data_key == default_key: if data_key and default_key and data_key == default_key:
raise RuntimeError( 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}=...)" f" and slot data ({SLOT_DATA_KWARG}=...)"
) )
return { return {
SLOT_NAME_KWARG: name,
SLOT_DEFAULT_KWARG: default_key, SLOT_DEFAULT_KWARG: default_key,
SLOT_DATA_KWARG: data_key, SLOT_DATA_KWARG: data_key,
} }
@ -276,16 +293,19 @@ class FillNode(BaseNode):
key: str, key: str,
name: str, name: str,
component_name: Optional[str] = None, component_name: Optional[str] = None,
identifier: bool = True,
) -> Optional[str]: ) -> Optional[str]:
if key not in kwargs: if key not in kwargs:
return None return None
value = kwargs[key] value = kwargs[key]
if not is_identifier(value): if identifier and not is_identifier(value):
raise RuntimeError( raise RuntimeError(
f"Fill tag {name} in component {component_name}" f"Fill tag {name} in component {component_name}"
f"does not resolve to a valid Python identifier, got '{value}'" 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 return value
@ -340,15 +360,15 @@ def _try_parse_as_named_fill_tag_set(
seen_names: Set[str] = set() seen_names: Set[str] = set()
for node in nodelist: for node in nodelist:
if isinstance(node, FillNode): if isinstance(node, FillNode):
# Check that, after we've resolved the names, that there's still no duplicates. # If the fill name was defined statically, then check for no duplicates.
# This makes sure that if two different variables refer to same string, we detect maybe_fill_name = node.kwargs.kwargs.get(SLOT_NAME_KWARG)
# them. if isinstance(maybe_fill_name, FilterExpression):
if node.name.token in seen_names: if maybe_fill_name.token in seen_names:
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: " f"Multiple fill tags cannot target the same slot name: "
f"Detected duplicate fill tag name '{node.name}'." f"Detected duplicate fill tag name '{maybe_fill_name.token}'."
) )
seen_names.add(node.name.token) seen_names.add(maybe_fill_name.token)
result.append(node) result.append(node)
elif isinstance(node, CommentNode): elif isinstance(node, CommentNode):
pass pass
@ -378,13 +398,50 @@ def _try_parse_as_default_fill(
return [ return [
FillNode( FillNode(
nodelist=nodelist, 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, 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 # SLOT RESOLUTION
#################### ####################
@ -427,13 +484,15 @@ def resolve_slots(
if not isinstance(node, SlotNode): if not isinstance(node, SlotNode):
return return
slot_name, _ = node.resolve_kwargs(context, component_name)
# 1. Collect slots # 1. Collect slots
# Basically we take all the important info form the SlotNode, so the logic is # 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 # less coupled to Django's Template/Node. Plain tuples should also help with
# troubleshooting. # troubleshooting.
slot = Slot( slot = Slot(
id=node.node_id, id=node.node_id,
name=node.name, name=slot_name,
nodelist=node.nodelist, nodelist=node.nodelist,
is_default=node.is_default, is_default=node.is_default,
is_required=node.is_required, is_required=node.is_required,

View file

@ -1,7 +1,7 @@
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Union from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Union
import django.template 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.template.exceptions import TemplateSyntaxError
from django.utils.safestring import SafeString, mark_safe from django.utils.safestring import SafeString, mark_safe
from django.utils.text import smart_split from django.utils.text import smart_split
@ -25,7 +25,6 @@ from django_components.expression import (
is_internal_spread_operator, is_internal_spread_operator,
is_kwarg, is_kwarg,
is_spread_operator, is_spread_operator,
resolve_string,
) )
from django_components.logger import trace_msg from django_components.logger import trace_msg
from django_components.middleware import ( from django_components.middleware import (
@ -33,11 +32,12 @@ from django_components.middleware import (
JS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER,
is_dependency_middleware_active, is_dependency_middleware_active,
) )
from django_components.provide import ProvideNode from django_components.provide import PROVIDE_NAME_KWARG, ProvideNode
from django_components.slots import ( from django_components.slots import (
SLOT_DATA_KWARG, SLOT_DATA_KWARG,
SLOT_DEFAULT_KEYWORD, SLOT_DEFAULT_KEYWORD,
SLOT_DEFAULT_KWARG, SLOT_DEFAULT_KWARG,
SLOT_NAME_KWARG,
SLOT_REQUIRED_KEYWORD, SLOT_REQUIRED_KEYWORD,
FillNode, FillNode,
SlotNode, SlotNode,
@ -45,7 +45,7 @@ from django_components.slots import (
) )
from django_components.tag_formatter import get_tag_formatter from django_components.tag_formatter import get_tag_formatter
from django_components.template_parser import parse_bits 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: if TYPE_CHECKING:
from django_components.component import Component from django_components.component import Component
@ -139,27 +139,30 @@ def slot(parser: Parser, token: Token) -> SlotNode:
"slot", "slot",
parser, parser,
token, token,
params=["name"], params=[SLOT_NAME_KWARG],
optional_params=[SLOT_NAME_KWARG],
flags=[SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD], flags=[SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD],
keywordonly_kwargs=True, keywordonly_kwargs=True,
repeatable_kwargs=False, repeatable_kwargs=False,
end_tag="endslot", 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() body = tag.parse_body()
slot_node = SlotNode( slot_node = SlotNode(
name=data.name,
nodelist=body, nodelist=body,
node_id=tag.id, node_id=tag.id,
kwargs=tag.kwargs, kwargs=tag.kwargs,
is_required=tag.flags[SLOT_REQUIRED_KEYWORD], is_required=tag.flags[SLOT_REQUIRED_KEYWORD],
is_default=tag.flags[SLOT_DEFAULT_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 return slot_node
@ -177,24 +180,27 @@ def fill(parser: Parser, token: Token) -> FillNode:
"fill", "fill",
parser, parser,
token, token,
params=["name"], params=[SLOT_NAME_KWARG],
optional_params=[SLOT_NAME_KWARG],
keywordonly_kwargs=[SLOT_DATA_KWARG, SLOT_DEFAULT_KWARG], keywordonly_kwargs=[SLOT_DATA_KWARG, SLOT_DEFAULT_KWARG],
repeatable_kwargs=False, repeatable_kwargs=False,
end_tag="endfill", 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() body = tag.parse_body()
fill_node = FillNode( fill_node = FillNode(
nodelist=body, nodelist=body,
name=slot_name,
node_id=tag.id, node_id=tag.id,
kwargs=tag.kwargs, 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 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 # Tag all fill nodes as children of this particular component instance
for node in fill_nodes: 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 node.component_id = tag.id
component_node = ComponentNode( component_node = ComponentNode(
@ -267,25 +273,28 @@ def provide(parser: Parser, token: Token) -> ProvideNode:
"provide", "provide",
parser, parser,
token, token,
params=["name"], params=[PROVIDE_NAME_KWARG],
optional_params=[PROVIDE_NAME_KWARG],
flags=[], flags=[],
keywordonly_kwargs=True, keywordonly_kwargs=True,
repeatable_kwargs=False, repeatable_kwargs=False,
end_tag="endprovide", 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() body = tag.parse_body()
slot_node = ProvideNode( slot_node = ProvideNode(
name=data.key,
nodelist=body, nodelist=body,
node_id=tag.id, node_id=tag.id,
kwargs=tag.kwargs, 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 return slot_node
@ -531,7 +540,9 @@ def _parse_tag(
else: else:
is_key_allowed = keywordonly_kwargs == True or key in keywordonly_kwargs # noqa: E712 is_key_allowed = keywordonly_kwargs == True or key in keywordonly_kwargs # noqa: E712
if not is_key_allowed: 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 # Check for repeated keys
if key in kwargs: if key in kwargs:
@ -674,43 +685,3 @@ def _fix_nested_tags(parser: Parser, block_token: Token) -> None:
expects_text = True expects_text = True
continue 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) 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): def test_tag_aggregate_args(self):
@register("test") @register("test")
class AttrsComponent(Component): class AttrsComponent(Component):

View file

@ -220,7 +220,7 @@ class ProvideTemplateTagTest(BaseTestCase):
) )
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_key_single_quotes(self): def test_provide_name_single_quotes(self):
@register("injectee") @register("injectee")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
@ -252,7 +252,87 @@ class ProvideTemplateTagTest(BaseTestCase):
) )
@parametrize_context_behavior(["django", "isolated"]) @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") @register("injectee")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
@ -272,11 +352,11 @@ class ProvideTemplateTagTest(BaseTestCase):
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
""" """
with self.assertRaises(TemplateSyntaxError): with self.assertRaisesMessage(RuntimeError, "Provide tag kwarg 'name' is missing"):
Template(template_str) Template(template_str).render(Context({}))
@parametrize_context_behavior(["django", "isolated"]) @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") @register("injectee")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """
@ -296,11 +376,11 @@ class ProvideTemplateTagTest(BaseTestCase):
{% component "injectee" %} {% component "injectee" %}
{% endcomponent %} {% endcomponent %}
""" """
with self.assertRaises(TemplateSyntaxError): with self.assertRaisesMessage(RuntimeError, "Provide tag kwarg 'name' is missing"):
Template(template_str) Template(template_str).render(Context({}))
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_key_must_be_identifier(self): def test_provide_name_must_be_identifier(self):
@register("injectee") @register("injectee")
class InjectComponent(Component): class InjectComponent(Component):
template: types.django_html = """ template: types.django_html = """

View file

@ -796,6 +796,80 @@ class ScopedSlotTest(BaseTestCase):
""" """
self.assertHTMLEqual(rendered, expected) 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"]) @parametrize_context_behavior(["django", "isolated"])
def test_slot_data_raises_on_slot_data_and_slot_default_same_var(self): def test_slot_data_raises_on_slot_data_and_slot_default_same_var(self):
@register("test") @register("test")
@ -823,7 +897,7 @@ class ScopedSlotTest(BaseTestCase):
""" """
with self.assertRaisesMessage( with self.assertRaisesMessage(
RuntimeError, 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()) Template(template).render(Context())
@ -905,6 +979,94 @@ class ScopedSlotTest(BaseTestCase):
expected = "<div> Default text </div>" expected = "<div> Default text </div>"
self.assertHTMLEqual(rendered, expected) 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"]) @parametrize_context_behavior(["django", "isolated"])
def test_nested_fills(self): def test_nested_fills(self):
@register("test") @register("test")