mirror of
https://github.com/django-components/django-components.git
synced 2025-07-13 03:45:00 +00:00
feat: add spread operator (#596)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
36b8fcfbe6
commit
d6ec62c6be
4 changed files with 637 additions and 108 deletions
243
README.md
243
README.md
|
@ -50,6 +50,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
|
||||||
- [Using slots in templates](#using-slots-in-templates)
|
- [Using slots in templates](#using-slots-in-templates)
|
||||||
- [Passing data to components](#passing-data-to-components)
|
- [Passing data to components](#passing-data-to-components)
|
||||||
- [Rendering HTML attributes](#rendering-html-attributes)
|
- [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)
|
- [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject)
|
||||||
- [Component context and scope](#component-context-and-scope)
|
- [Component context and scope](#component-context-and-scope)
|
||||||
- [Customizing component tags with TagFormatter](#customizing-component-tags-with-tagformatter)
|
- [Customizing component tags with TagFormatter](#customizing-component-tags-with-tagformatter)
|
||||||
|
@ -1446,105 +1447,6 @@ As seen above, you can pass arguments to components like so:
|
||||||
</body>
|
</body>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Special characters
|
|
||||||
|
|
||||||
_New in version 0.71_:
|
|
||||||
|
|
||||||
Keyword arguments can contain special characters `# @ . - _`, so keywords like
|
|
||||||
so are still valid:
|
|
||||||
|
|
||||||
```django
|
|
||||||
<body>
|
|
||||||
{% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True / %}
|
|
||||||
</body>
|
|
||||||
```
|
|
||||||
|
|
||||||
These can then be accessed inside `get_context_data` so:
|
|
||||||
|
|
||||||
```py
|
|
||||||
@register("calendar")
|
|
||||||
class Calendar(Component):
|
|
||||||
# Since # . @ - are not valid identifiers, we have to
|
|
||||||
# use `**kwargs` so the method can accept these args.
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
return {
|
|
||||||
"date": kwargs["my-date"],
|
|
||||||
"id": kwargs["#some_id"],
|
|
||||||
"on_click": kwargs["@click.native"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pass dictonary by its key-value pairs
|
|
||||||
|
|
||||||
_New in version 0.74_:
|
|
||||||
|
|
||||||
Sometimes, a component may expect a dictionary as one of its inputs.
|
|
||||||
|
|
||||||
Most commonly, this happens when a component accepts a dictionary
|
|
||||||
of HTML attributes (usually called `attrs`) to pass to the underlying template.
|
|
||||||
|
|
||||||
In such cases, we may want to define some HTML attributes statically, and other dynamically.
|
|
||||||
But for that, we need to define this dictionary on Python side:
|
|
||||||
|
|
||||||
```py
|
|
||||||
@register("my_comp")
|
|
||||||
class MyComp(Component):
|
|
||||||
template = """
|
|
||||||
{% component "other" attrs=attrs / %}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_context_data(self, some_id: str):
|
|
||||||
attrs = {
|
|
||||||
"class": "pa-4 flex",
|
|
||||||
"data-some-id": some_id,
|
|
||||||
"@click.stop": "onClickHandler",
|
|
||||||
}
|
|
||||||
return {"attrs": attrs}
|
|
||||||
```
|
|
||||||
|
|
||||||
But as you can see in the case above, the event handler `@click.stop` and styling `pa-4 flex`
|
|
||||||
are disconnected from the template. If the component grew in size and we moved the HTML
|
|
||||||
to a separate file, we would have hard time reasoning about the component's template.
|
|
||||||
|
|
||||||
Luckily, there's a better way.
|
|
||||||
|
|
||||||
When we want to pass a dictionary to a component, we can define individual key-value pairs
|
|
||||||
as component kwargs, so we can keep all the relevant information in the template. For that,
|
|
||||||
we prefix the key with the name of the dict and `:`. So key `class` of input `attrs` becomes
|
|
||||||
`attrs:class`. And our example becomes:
|
|
||||||
|
|
||||||
```py
|
|
||||||
@register("my_comp")
|
|
||||||
class MyComp(Component):
|
|
||||||
template = """
|
|
||||||
{% component "other"
|
|
||||||
attrs:class="pa-4 flex"
|
|
||||||
attrs:data-some-id=some_id
|
|
||||||
attrs:@click.stop="onClickHandler"
|
|
||||||
/ %}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_context_data(self, some_id: str):
|
|
||||||
return {"some_id": some_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
Sweet! Now all the relevant HTML is inside the template, and we can move it to a separate file with confidence:
|
|
||||||
|
|
||||||
```django
|
|
||||||
{% component "other"
|
|
||||||
attrs:class="pa-4 flex"
|
|
||||||
attrs:data-some-id=some_id
|
|
||||||
attrs:@click.stop="onClickHandler"
|
|
||||||
/ %}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Note: It is NOT possible to define nested dictionaries, so
|
|
||||||
> `attrs:my_key:two=2` would be interpreted as:
|
|
||||||
>
|
|
||||||
> ```py
|
|
||||||
> {"attrs": {"my_key:two": 2}}
|
|
||||||
> ```
|
|
||||||
|
|
||||||
### Accessing data passed to the component
|
### 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`.
|
||||||
|
@ -1953,6 +1855,149 @@ attributes_to_string(attrs)
|
||||||
# 'class="my-class text-red pa-4" data-id="123" required'
|
# 'class="my-class text-red pa-4" data-id="123" required'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### Special characters
|
||||||
|
|
||||||
|
_New in version 0.71_:
|
||||||
|
|
||||||
|
Keyword arguments can contain special characters `# @ . - _`, so keywords like
|
||||||
|
so are still valid:
|
||||||
|
|
||||||
|
```django
|
||||||
|
<body>
|
||||||
|
{% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True / %}
|
||||||
|
</body>
|
||||||
|
```
|
||||||
|
|
||||||
|
These can then be accessed inside `get_context_data` so:
|
||||||
|
|
||||||
|
```py
|
||||||
|
@register("calendar")
|
||||||
|
class Calendar(Component):
|
||||||
|
# Since # . @ - are not valid identifiers, we have to
|
||||||
|
# use `**kwargs` so the method can accept these args.
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return {
|
||||||
|
"date": kwargs["my-date"],
|
||||||
|
"id": kwargs["#some_id"],
|
||||||
|
"on_click": kwargs["@click.native"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spread operator
|
||||||
|
|
||||||
|
_New in version 0.93_:
|
||||||
|
|
||||||
|
Instead of passing keyword arguments one-by-one:
|
||||||
|
|
||||||
|
```django
|
||||||
|
{% component "calendar" title="How to abc" date="2015-06-19" author="John Wick" / %}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can use a spread operator `...dict` to apply key-value pairs from a dictionary:
|
||||||
|
|
||||||
|
```py
|
||||||
|
post_data = {
|
||||||
|
"title": "How to...",
|
||||||
|
"date": "2015-06-19",
|
||||||
|
"author": "John Wick",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```django
|
||||||
|
{% component "calendar" ...post_data / %}
|
||||||
|
```
|
||||||
|
|
||||||
|
This behaves similar to [JSX's spread operator](https://kevinyckim33.medium.com/jsx-spread-operator-component-props-meaning-3c9bcadd2493)
|
||||||
|
or [Vue's `v-bind`](https://vuejs.org/api/built-in-directives.html#v-bind).
|
||||||
|
|
||||||
|
Spread operators are treated as keyword arguments, which means that:
|
||||||
|
1. Spread operators must come after positional arguments.
|
||||||
|
2. You cannot use spread operators for [positional-only arguments](https://martinxpn.medium.com/positional-only-and-keyword-only-arguments-in-python-37-100-days-of-python-310c311657b0).
|
||||||
|
|
||||||
|
Other than that, you can use spread operators multiple times, and even put keyword arguments in-between or after them:
|
||||||
|
|
||||||
|
```django
|
||||||
|
{% component "calendar" ...post_data id=post.id ...extra / %}
|
||||||
|
```
|
||||||
|
|
||||||
|
In a case of conflicts, the values added later (right-most) overwrite previous values.
|
||||||
|
|
||||||
|
### Pass dictonary by its key-value pairs
|
||||||
|
|
||||||
|
_New in version 0.74_:
|
||||||
|
|
||||||
|
Sometimes, a component may expect a dictionary as one of its inputs.
|
||||||
|
|
||||||
|
Most commonly, this happens when a component accepts a dictionary
|
||||||
|
of HTML attributes (usually called `attrs`) to pass to the underlying template.
|
||||||
|
|
||||||
|
In such cases, we may want to define some HTML attributes statically, and other dynamically.
|
||||||
|
But for that, we need to define this dictionary on Python side:
|
||||||
|
|
||||||
|
```py
|
||||||
|
@register("my_comp")
|
||||||
|
class MyComp(Component):
|
||||||
|
template = """
|
||||||
|
{% component "other" attrs=attrs / %}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_context_data(self, some_id: str):
|
||||||
|
attrs = {
|
||||||
|
"class": "pa-4 flex",
|
||||||
|
"data-some-id": some_id,
|
||||||
|
"@click.stop": "onClickHandler",
|
||||||
|
}
|
||||||
|
return {"attrs": attrs}
|
||||||
|
```
|
||||||
|
|
||||||
|
But as you can see in the case above, the event handler `@click.stop` and styling `pa-4 flex`
|
||||||
|
are disconnected from the template. If the component grew in size and we moved the HTML
|
||||||
|
to a separate file, we would have hard time reasoning about the component's template.
|
||||||
|
|
||||||
|
Luckily, there's a better way.
|
||||||
|
|
||||||
|
When we want to pass a dictionary to a component, we can define individual key-value pairs
|
||||||
|
as component kwargs, so we can keep all the relevant information in the template. For that,
|
||||||
|
we prefix the key with the name of the dict and `:`. So key `class` of input `attrs` becomes
|
||||||
|
`attrs:class`. And our example becomes:
|
||||||
|
|
||||||
|
```py
|
||||||
|
@register("my_comp")
|
||||||
|
class MyComp(Component):
|
||||||
|
template = """
|
||||||
|
{% component "other"
|
||||||
|
attrs:class="pa-4 flex"
|
||||||
|
attrs:data-some-id=some_id
|
||||||
|
attrs:@click.stop="onClickHandler"
|
||||||
|
/ %}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_context_data(self, some_id: str):
|
||||||
|
return {"some_id": some_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sweet! Now all the relevant HTML is inside the template, and we can move it to a separate file with confidence:
|
||||||
|
|
||||||
|
```django
|
||||||
|
{% component "other"
|
||||||
|
attrs:class="pa-4 flex"
|
||||||
|
attrs:data-some-id=some_id
|
||||||
|
attrs:@click.stop="onClickHandler"
|
||||||
|
/ %}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: It is NOT possible to define nested dictionaries, so
|
||||||
|
> `attrs:my_key:two=2` would be interpreted as:
|
||||||
|
>
|
||||||
|
> ```py
|
||||||
|
> {"attrs": {"my_key:two": 2}}
|
||||||
|
> ```
|
||||||
|
|
||||||
## Prop drilling and dependency injection (provide / inject)
|
## Prop drilling and dependency injection (provide / inject)
|
||||||
|
|
||||||
_New in version 0.80_:
|
_New in version 0.80_:
|
||||||
|
|
|
@ -1,11 +1,36 @@
|
||||||
|
import re
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
|
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
|
||||||
|
|
||||||
from django.template import Context, TemplateSyntaxError
|
from django.template import Context, TemplateSyntaxError
|
||||||
from django.template.base import FilterExpression, Parser
|
from django.template.base import FilterExpression, Parser
|
||||||
|
|
||||||
Expression = Union[FilterExpression]
|
Expression = Union[FilterExpression]
|
||||||
RuntimeKwargsInput = Dict[str, Expression]
|
RuntimeKwargsInput = Dict[str, Union[Expression, "Operator"]]
|
||||||
RuntimeKwargPairsInput = List[Tuple[str, Expression]]
|
RuntimeKwargPairsInput = List[Tuple[str, Union[Expression, "Operator"]]]
|
||||||
|
|
||||||
|
|
||||||
|
class Operator(ABC):
|
||||||
|
"""
|
||||||
|
Operator describes something that somehow changes the inputs
|
||||||
|
to template tags (the `{% %}`).
|
||||||
|
|
||||||
|
For example, a SpreadOperator inserts one or more kwargs at the
|
||||||
|
specified location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def resolve(self, context: Context) -> Any: ... # noqa E704
|
||||||
|
|
||||||
|
|
||||||
|
class SpreadOperator(Operator):
|
||||||
|
"""Operator that inserts one or more kwargs at the specified location."""
|
||||||
|
|
||||||
|
def __init__(self, expr: Expression) -> None:
|
||||||
|
self.expr = expr
|
||||||
|
|
||||||
|
def resolve(self, context: Context) -> Dict[str, Any]:
|
||||||
|
return self.expr.resolve(context)
|
||||||
|
|
||||||
|
|
||||||
class RuntimeKwargs:
|
class RuntimeKwargs:
|
||||||
|
@ -24,7 +49,12 @@ class RuntimeKwargPairs:
|
||||||
def resolve(self, context: Context) -> List[Tuple[str, Any]]:
|
def resolve(self, context: Context) -> List[Tuple[str, Any]]:
|
||||||
resolved_kwarg_pairs: List[Tuple[str, Any]] = []
|
resolved_kwarg_pairs: List[Tuple[str, Any]] = []
|
||||||
for key, kwarg in self.kwarg_pairs:
|
for key, kwarg in self.kwarg_pairs:
|
||||||
resolved_kwarg_pairs.append((key, kwarg.resolve(context)))
|
if isinstance(kwarg, SpreadOperator):
|
||||||
|
spread_kwargs = kwarg.resolve(context)
|
||||||
|
for spread_key, spread_value in spread_kwargs.items():
|
||||||
|
resolved_kwarg_pairs.append((spread_key, spread_value))
|
||||||
|
else:
|
||||||
|
resolved_kwarg_pairs.append((key, kwarg.resolve(context)))
|
||||||
|
|
||||||
return resolved_kwarg_pairs
|
return resolved_kwarg_pairs
|
||||||
|
|
||||||
|
@ -43,12 +73,19 @@ def safe_resolve_list(context: Context, args: List[Expression]) -> List:
|
||||||
|
|
||||||
def safe_resolve_dict(
|
def safe_resolve_dict(
|
||||||
context: Context,
|
context: Context,
|
||||||
kwargs: Dict[str, Expression],
|
kwargs: Dict[str, Union[Expression, "Operator"]],
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
for key, kwarg in kwargs.items():
|
for key, kwarg in kwargs.items():
|
||||||
result[key] = kwarg.resolve(context)
|
# If we've come across a Spread Operator (...), we insert the kwargs from it here
|
||||||
|
if isinstance(kwarg, SpreadOperator):
|
||||||
|
spread_dict = kwarg.resolve(context)
|
||||||
|
if spread_dict is not None:
|
||||||
|
for spreadkey, spreadkwarg in spread_dict.items():
|
||||||
|
result[spreadkey] = spreadkwarg
|
||||||
|
else:
|
||||||
|
result[key] = kwarg.resolve(context)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@ -72,6 +109,31 @@ def is_aggregate_key(key: str) -> bool:
|
||||||
return ":" in key and not key.startswith(":")
|
return ":" in key and not key.startswith(":")
|
||||||
|
|
||||||
|
|
||||||
|
def is_spread_operator(value: Any) -> bool:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return value.startswith("...")
|
||||||
|
|
||||||
|
|
||||||
|
# A string that starts with `...1=`, `...29=`, etc.
|
||||||
|
# We convert the spread syntax to this, so Django parses
|
||||||
|
# it as a kwarg, so it remains in the original position.
|
||||||
|
#
|
||||||
|
# So from `...dict`, we make `...1=dict`
|
||||||
|
#
|
||||||
|
# That way it's trivial to merge the kwargs after the spread
|
||||||
|
# operator is replaced with actual values.
|
||||||
|
INTERNAL_SPREAD_OPERATOR_RE = re.compile(r"^\.\.\.\d+=")
|
||||||
|
|
||||||
|
|
||||||
|
def is_internal_spread_operator(value: Any) -> bool:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return bool(INTERNAL_SPREAD_OPERATOR_RE.match(value))
|
||||||
|
|
||||||
|
|
||||||
def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
|
def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
This function aggregates "prefixed" kwargs into dicts. "Prefixed" kwargs
|
This function aggregates "prefixed" kwargs into dicts. "Prefixed" kwargs
|
||||||
|
|
|
@ -12,11 +12,16 @@ from django_components.component_registry import ComponentRegistry
|
||||||
from django_components.component_registry import registry as component_registry
|
from django_components.component_registry import registry as component_registry
|
||||||
from django_components.expression import (
|
from django_components.expression import (
|
||||||
Expression,
|
Expression,
|
||||||
|
Operator,
|
||||||
RuntimeKwargPairs,
|
RuntimeKwargPairs,
|
||||||
|
RuntimeKwargPairsInput,
|
||||||
RuntimeKwargs,
|
RuntimeKwargs,
|
||||||
RuntimeKwargsInput,
|
RuntimeKwargsInput,
|
||||||
|
SpreadOperator,
|
||||||
is_aggregate_key,
|
is_aggregate_key,
|
||||||
|
is_internal_spread_operator,
|
||||||
is_kwarg,
|
is_kwarg,
|
||||||
|
is_spread_operator,
|
||||||
resolve_string,
|
resolve_string,
|
||||||
)
|
)
|
||||||
from django_components.logger import trace_msg
|
from django_components.logger import trace_msg
|
||||||
|
@ -389,6 +394,7 @@ def _parse_tag(
|
||||||
else:
|
else:
|
||||||
seen_kwargs.add(key)
|
seen_kwargs.add(key)
|
||||||
|
|
||||||
|
spread_count = 0
|
||||||
for bit in bits:
|
for bit in bits:
|
||||||
value = bit
|
value = bit
|
||||||
bit_is_kwarg = is_kwarg(bit)
|
bit_is_kwarg = is_kwarg(bit)
|
||||||
|
@ -411,6 +417,21 @@ def _parse_tag(
|
||||||
parsed_flags[value] = True
|
parsed_flags[value] = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Extract spread operator (...dict)
|
||||||
|
elif is_spread_operator(value):
|
||||||
|
if value == "...":
|
||||||
|
raise TemplateSyntaxError("Syntax operator is missing a value")
|
||||||
|
|
||||||
|
# Replace the leading `...` with `...=`, so the parser
|
||||||
|
# interprets it as a kwargs, and keeps it in the correct
|
||||||
|
# position.
|
||||||
|
# Since there can be multiple spread operators, we suffix
|
||||||
|
# them with an index, e.g. `...0=`
|
||||||
|
internal_spread_bit = f"...{spread_count}={value[3:]}"
|
||||||
|
bits_without_flags.append(internal_spread_bit)
|
||||||
|
spread_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
bits_without_flags.append(bit)
|
bits_without_flags.append(bit)
|
||||||
|
|
||||||
bits = bits_without_flags
|
bits = bits_without_flags
|
||||||
|
@ -450,13 +471,25 @@ def _parse_tag(
|
||||||
params = [param for param in params_to_sort if param not in optional_params]
|
params = [param for param in params_to_sort if param not in optional_params]
|
||||||
|
|
||||||
# Parse args/kwargs that will be passed to the fill
|
# Parse args/kwargs that will be passed to the fill
|
||||||
args, kwarg_pairs = parse_bits(
|
args, raw_kwarg_pairs = parse_bits(
|
||||||
parser=parser,
|
parser=parser,
|
||||||
bits=bits,
|
bits=bits,
|
||||||
params=[] if isinstance(params, bool) else params,
|
params=[] if isinstance(params, bool) else params,
|
||||||
name=tag_name,
|
name=tag_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Post-process args/kwargs - Mark special cases like aggregate dicts
|
||||||
|
# or dynamic expressions
|
||||||
|
kwarg_pairs: RuntimeKwargPairsInput = []
|
||||||
|
for key, val in raw_kwarg_pairs:
|
||||||
|
is_spread_op = is_internal_spread_operator(key + "=")
|
||||||
|
|
||||||
|
if is_spread_op:
|
||||||
|
expr = parser.compile_filter(val.token)
|
||||||
|
kwarg_pairs.append((key, SpreadOperator(expr)))
|
||||||
|
else:
|
||||||
|
kwarg_pairs.append((key, val))
|
||||||
|
|
||||||
# Allow only as many positional args as given
|
# Allow only as many positional args as given
|
||||||
if params != True and len(args) > len(params): # noqa F712
|
if params != True and len(args) > len(params): # noqa F712
|
||||||
raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {args[len(params):]}")
|
raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {args[len(params):]}")
|
||||||
|
@ -471,6 +504,11 @@ def _parse_tag(
|
||||||
kwargs: RuntimeKwargsInput = {}
|
kwargs: RuntimeKwargsInput = {}
|
||||||
extra_keywords: Set[str] = set()
|
extra_keywords: Set[str] = set()
|
||||||
for key, val in kwarg_pairs:
|
for key, val in kwarg_pairs:
|
||||||
|
# Operators are resolved at render-time, so skip them
|
||||||
|
if isinstance(val, Operator):
|
||||||
|
kwargs[key] = val
|
||||||
|
continue
|
||||||
|
|
||||||
# Check if key allowed
|
# Check if key allowed
|
||||||
if not keywordonly_kwargs:
|
if not keywordonly_kwargs:
|
||||||
is_key_allowed = False
|
is_key_allowed = False
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
"""Catch-all for tests that use template tags and don't fit other files"""
|
"""Catch-all for tests that use template tags and don't fit other files"""
|
||||||
|
|
||||||
from typing import Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from django.template import Context, Template
|
from django.template import Context, Template, TemplateSyntaxError
|
||||||
from django.template.base import Parser
|
from django.template.base import Parser
|
||||||
|
|
||||||
|
from django_components import Component, register, types
|
||||||
from django_components.expression import safe_resolve_dict, safe_resolve_list
|
from django_components.expression import safe_resolve_dict, safe_resolve_list
|
||||||
|
|
||||||
from .django_test_setup import setup_test_config
|
from .django_test_setup import setup_test_config
|
||||||
from .testutils import BaseTestCase
|
from .testutils import BaseTestCase, parametrize_context_behavior
|
||||||
|
|
||||||
setup_test_config({"autodiscover": False})
|
setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
@ -58,3 +59,386 @@ class ResolveTests(BaseTestCase):
|
||||||
safe_resolve_dict(ctx, exprs),
|
safe_resolve_dict(ctx, exprs),
|
||||||
{"a": 123, "b": [{}, {}], "c": ""},
|
{"a": 123, "b": [{}, {}], "c": ""},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SpreadOperatorTests(BaseTestCase):
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_component(self):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
@register("test")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
def get_context_data(
|
||||||
|
self,
|
||||||
|
pos_var1: Any,
|
||||||
|
*args: Any,
|
||||||
|
**kwargs: Any,
|
||||||
|
):
|
||||||
|
nonlocal captured
|
||||||
|
captured = kwargs
|
||||||
|
|
||||||
|
return {
|
||||||
|
"pos_var1": pos_var1,
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
|
||||||
|
template: types.django_html = """
|
||||||
|
<div>{{ pos_var1 }}</div>
|
||||||
|
<div>{{ attrs }}</div>
|
||||||
|
<div>{{ items }}</div>
|
||||||
|
<div>{{ a }}</div>
|
||||||
|
<div>{{ x }}</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_str: types.django_html = (
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component 'test'
|
||||||
|
var_a
|
||||||
|
...my_dict
|
||||||
|
...item
|
||||||
|
x=123
|
||||||
|
/ %}
|
||||||
|
""".replace(
|
||||||
|
"\n", " "
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
template = Template(template_str)
|
||||||
|
rendered = template.render(
|
||||||
|
Context(
|
||||||
|
{
|
||||||
|
"var_a": "LoREM",
|
||||||
|
"my_dict": {
|
||||||
|
"attrs:@click": "() => {}",
|
||||||
|
"attrs:style": "height: 20px",
|
||||||
|
"items": [1, 2, 3],
|
||||||
|
},
|
||||||
|
"item": {"a": 1},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that variables passed to the component are of correct type
|
||||||
|
self.assertEqual(captured["attrs"], {"@click": "() => {}", "style": "height: 20px"})
|
||||||
|
self.assertEqual(captured["items"], [1, 2, 3])
|
||||||
|
self.assertEqual(captured["a"], 1)
|
||||||
|
self.assertEqual(captured["x"], 123)
|
||||||
|
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
<div>LoREM</div>
|
||||||
|
<div>{'@click': '() => {}', 'style': 'height: 20px'}</div>
|
||||||
|
<div>[1, 2, 3]</div>
|
||||||
|
<div>1</div>
|
||||||
|
<div>123</div>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_slot(self):
|
||||||
|
@register("test")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
def get_context_data(self):
|
||||||
|
return {
|
||||||
|
"my_dict": {
|
||||||
|
"attrs:@click": "() => {}",
|
||||||
|
"attrs:style": "height: 20px",
|
||||||
|
"items": [1, 2, 3],
|
||||||
|
},
|
||||||
|
"item": {"a": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
{% slot "my_slot" ...my_dict ...item x=123 default / %}
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_str: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component 'test' %}
|
||||||
|
{% fill "my_slot" data="slot_data" %}
|
||||||
|
{{ slot_data }}
|
||||||
|
{% endfill %}
|
||||||
|
{% endcomponent %}
|
||||||
|
"""
|
||||||
|
template = Template(template_str)
|
||||||
|
rendered = template.render(Context({}))
|
||||||
|
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
{'items': [1, 2, 3], 'a': 1, 'x': 123, 'attrs': {'@click': '() => {}', 'style': 'height: 20px'}}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_fill(self):
|
||||||
|
@register("test")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
def get_context_data(self):
|
||||||
|
return {
|
||||||
|
"my_dict": {
|
||||||
|
"attrs:@click": "() => {}",
|
||||||
|
"attrs:style": "height: 20px",
|
||||||
|
"items": [1, 2, 3],
|
||||||
|
},
|
||||||
|
"item": {"a": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
{% slot "my_slot" ...my_dict ...item x=123 default %}
|
||||||
|
__SLOT_DEFAULT__
|
||||||
|
{% endslot %}
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_str: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component 'test' %}
|
||||||
|
{% fill "my_slot" ...fill_data %}
|
||||||
|
{{ slot_data }}
|
||||||
|
{{ slot_default }}
|
||||||
|
{% endfill %}
|
||||||
|
{% endcomponent %}
|
||||||
|
"""
|
||||||
|
template = Template(template_str)
|
||||||
|
rendered = template.render(
|
||||||
|
Context(
|
||||||
|
{
|
||||||
|
"fill_data": {
|
||||||
|
"data": "slot_data",
|
||||||
|
"default": "slot_default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
{'items': [1, 2, 3], 'a': 1, 'x': 123, 'attrs': {'@click': '() => {}', 'style': 'height: 20px'}}
|
||||||
|
__SLOT_DEFAULT__
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_provide(self):
|
||||||
|
@register("test")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
def get_context_data(self):
|
||||||
|
data = self.inject("test")
|
||||||
|
return {
|
||||||
|
"attrs": data.attrs,
|
||||||
|
"items": data.items,
|
||||||
|
"a": data.a,
|
||||||
|
}
|
||||||
|
|
||||||
|
template: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
<div>{{ attrs }}</div>
|
||||||
|
<div>{{ items }}</div>
|
||||||
|
<div>{{ a }}</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_str: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
{% provide 'test' ...my_dict ...item %}
|
||||||
|
{% component 'test' / %}
|
||||||
|
{% endprovide %}
|
||||||
|
"""
|
||||||
|
template = Template(template_str)
|
||||||
|
rendered = template.render(
|
||||||
|
Context(
|
||||||
|
{
|
||||||
|
"my_dict": {
|
||||||
|
"attrs:@click": "() => {}",
|
||||||
|
"attrs:style": "height: 20px",
|
||||||
|
"items": [1, 2, 3],
|
||||||
|
},
|
||||||
|
"item": {"a": 1},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
<div>{'@click': '() => {}', 'style': 'height: 20px'}</div>
|
||||||
|
<div>[1, 2, 3]</div>
|
||||||
|
<div>1</div>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_html_attrs(self):
|
||||||
|
template_str: types.django_html = """
|
||||||
|
{% load component_tags %}
|
||||||
|
<div {% html_attrs defaults:test="hi" ...my_dict attrs:lol="123" %}>
|
||||||
|
"""
|
||||||
|
template = Template(template_str)
|
||||||
|
rendered = template.render(
|
||||||
|
Context(
|
||||||
|
{
|
||||||
|
"my_dict": {
|
||||||
|
"attrs:style": "height: 20px",
|
||||||
|
"class": "button",
|
||||||
|
"defaults:class": "my-class",
|
||||||
|
"defaults:style": "NONO",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
<div test="hi" class="my-class button" style="height: 20px" lol="123">
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_later_spreads_overwrite_earlier(self):
|
||||||
|
@register("test")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
def get_context_data(
|
||||||
|
self,
|
||||||
|
*args: Any,
|
||||||
|
**kwargs: Any,
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
|
||||||
|
template: types.django_html = """
|
||||||
|
<div>{{ attrs }}</div>
|
||||||
|
<div>{{ items }}</div>
|
||||||
|
<div>{{ a }}</div>
|
||||||
|
<div>{{ x }}</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_str: types.django_html = (
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component 'test'
|
||||||
|
...my_dict
|
||||||
|
attrs:style="OVERWRITTEN"
|
||||||
|
x=123
|
||||||
|
...item
|
||||||
|
/ %}
|
||||||
|
""".replace(
|
||||||
|
"\n", " "
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
template = Template(template_str)
|
||||||
|
rendered = template.render(
|
||||||
|
Context(
|
||||||
|
{
|
||||||
|
"my_dict": {
|
||||||
|
"attrs:@click": "() => {}",
|
||||||
|
"attrs:style": "height: 20px",
|
||||||
|
"items": [1, 2, 3],
|
||||||
|
},
|
||||||
|
"item": {"a": 1, "x": "OVERWRITTEN_X"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
<div>{'@click': '() => {}', 'style': 'OVERWRITTEN'}</div>
|
||||||
|
<div>[1, 2, 3]</div>
|
||||||
|
<div>1</div>
|
||||||
|
<div>OVERWRITTEN_X</div>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_raises_if_position_arg_after_spread(self):
|
||||||
|
@register("test")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
pass
|
||||||
|
|
||||||
|
template_str: types.django_html = (
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component 'test'
|
||||||
|
...my_dict
|
||||||
|
var_a
|
||||||
|
...item
|
||||||
|
x=123
|
||||||
|
/ %}
|
||||||
|
""".replace(
|
||||||
|
"\n", " "
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
TemplateSyntaxError, "'component' received some positional argument(s) after some keyword argument(s)"
|
||||||
|
):
|
||||||
|
Template(template_str)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_raises_on_missing_value(self):
|
||||||
|
@register("test")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
pass
|
||||||
|
|
||||||
|
template_str: types.django_html = (
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component 'test'
|
||||||
|
var_a
|
||||||
|
...
|
||||||
|
/ %}
|
||||||
|
""".replace(
|
||||||
|
"\n", " "
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesMessage(TemplateSyntaxError, "Syntax operator is missing a value"):
|
||||||
|
Template(template_str)
|
||||||
|
|
||||||
|
@parametrize_context_behavior(["django", "isolated"])
|
||||||
|
def test_raises_on_non_dict(self):
|
||||||
|
@register("test")
|
||||||
|
class SimpleComponent(Component):
|
||||||
|
pass
|
||||||
|
|
||||||
|
template_str: types.django_html = (
|
||||||
|
"""
|
||||||
|
{% load component_tags %}
|
||||||
|
{% component 'test'
|
||||||
|
var_a
|
||||||
|
...var_b
|
||||||
|
/ %}
|
||||||
|
""".replace(
|
||||||
|
"\n", " "
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
template = Template(template_str)
|
||||||
|
|
||||||
|
# List
|
||||||
|
with self.assertRaisesMessage(AttributeError, "'list' object has no attribute 'items'"):
|
||||||
|
template.render(
|
||||||
|
Context(
|
||||||
|
{
|
||||||
|
"var_a": "abc",
|
||||||
|
"var_b": [1, 2, 3],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# String
|
||||||
|
with self.assertRaisesMessage(AttributeError, "'str' object has no attribute 'items'"):
|
||||||
|
template.render(
|
||||||
|
Context(
|
||||||
|
{
|
||||||
|
"var_a": "abc",
|
||||||
|
"var_b": "def",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue