mirror of
https://github.com/django-components/django-components.git
synced 2025-08-17 12:40:15 +00:00
feat: add dynamic expressions (#605)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
fc5ea78739
commit
39cff5a1d0
5 changed files with 764 additions and 48 deletions
103
README.md
103
README.md
|
@ -65,6 +65,10 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
|
|||
|
||||
## Release notes
|
||||
|
||||
**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))
|
||||
|
||||
🚨📢 **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))
|
||||
|
||||
|
@ -1927,6 +1931,105 @@ Other than that, you can use spread operators multiple times, and even put keywo
|
|||
|
||||
In a case of conflicts, the values added later (right-most) overwrite previous values.
|
||||
|
||||
### Use template tags inside component inputs
|
||||
|
||||
_New in version 0.93_
|
||||
|
||||
When passing data around, sometimes you may need to do light transformations, like negating booleans or filtering lists.
|
||||
|
||||
Normally, what you would have to do is to define ALL the variables
|
||||
inside `get_context_data()`. But this can get messy if your components contain a lot of logic.
|
||||
|
||||
```py
|
||||
@register("calendar")
|
||||
class Calendar(Component):
|
||||
def get_context_data(self, id: str, editable: bool):
|
||||
return {
|
||||
"editable": editable,
|
||||
"readonly": not editable,
|
||||
"input_id": f"input-{id}",
|
||||
"icon_id": f"icon-{id}",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Instead, template tags in django_components (`{% component %}`, `{% slot %}`, `{% provide %}`, etc) allow you to treat literal string values as templates:
|
||||
|
||||
```django
|
||||
{% component 'blog_post'
|
||||
"As positional arg {# yay #}"
|
||||
title="{{ person.first_name }} {{ person.last_name }}"
|
||||
id="{% random_int 10 20 %}"
|
||||
author="John Wick {# TODO: parametrize #}"
|
||||
/ %}
|
||||
```
|
||||
|
||||
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 `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).
|
||||
|
||||
#### Passing data as string vs original values
|
||||
|
||||
Sometimes you may want to use the template tags to transform
|
||||
or generate the data that is then passed to the component.
|
||||
|
||||
The data doesn't necessarily have to be strings. In the example above, the kwarg `id` was passed as an integer, NOT a string.
|
||||
|
||||
Although the string literals for components inputs are treated as regular Django templates, there is one special case:
|
||||
|
||||
When the string literal contains only a single template tag, with no extra text, then the value is passed as the original type instead of a string.
|
||||
|
||||
Here, `page` is an integer:
|
||||
|
||||
```django
|
||||
{% component 'blog_post' page="{% random_int 10 20 %}" / %}
|
||||
```
|
||||
|
||||
Here, `page` is a string:
|
||||
|
||||
```django
|
||||
{% component 'blog_post' page=" {% random_int 10 20 %} " / %}
|
||||
```
|
||||
|
||||
And same applies to the `{{ }}` variable tags:
|
||||
|
||||
Here, `items` is a list:
|
||||
|
||||
```django
|
||||
{% component 'cat_list' items="{{ cats|slice:':2' }}" / %}
|
||||
```
|
||||
|
||||
Here, `items` is a string:
|
||||
|
||||
```django
|
||||
{% component 'cat_list' items="{{ cats|slice:':2' }} See more" / %}
|
||||
```
|
||||
|
||||
#### Evaluating Python expressions in template
|
||||
|
||||
You can even go a step further and have a similar experience to Vue or React,
|
||||
where you can evaluate arbitrary code expressions:
|
||||
|
||||
```jsx
|
||||
<MyForm
|
||||
value={ isEnabled ? inputValue : null }
|
||||
/>
|
||||
```
|
||||
|
||||
Similar is possible with [`django-expr`](https://pypi.org/project/django-expr/), which adds an `expr` tag and filter that you can use to evaluate Python expressions from within the template:
|
||||
|
||||
```django
|
||||
{% component "my_form"
|
||||
value="{% expr 'input_value if is_enabled else None' %}"
|
||||
/ %}
|
||||
```
|
||||
|
||||
> Note: Never use this feature to mix business logic and template logic. Business logic should still be in the template!
|
||||
|
||||
### Pass dictonary by its key-value pairs
|
||||
|
||||
_New in version 0.74_:
|
||||
|
|
|
@ -2,10 +2,10 @@ import re
|
|||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
|
||||
|
||||
from django.template import Context, TemplateSyntaxError
|
||||
from django.template.base import FilterExpression, Parser
|
||||
from django.template import Context, Node, NodeList, TemplateSyntaxError
|
||||
from django.template.base import FilterExpression, Lexer, Parser, VariableNode
|
||||
|
||||
Expression = Union[FilterExpression]
|
||||
Expression = Union[FilterExpression, "DynamicFilterExpression"]
|
||||
RuntimeKwargsInput = Dict[str, Union[Expression, "Operator"]]
|
||||
RuntimeKwargPairsInput = List[Tuple[str, Union[Expression, "Operator"]]]
|
||||
|
||||
|
@ -30,7 +30,77 @@ class SpreadOperator(Operator):
|
|||
self.expr = expr
|
||||
|
||||
def resolve(self, context: Context) -> Dict[str, Any]:
|
||||
return self.expr.resolve(context)
|
||||
data = self.expr.resolve(context)
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"Spread operator expression must resolve to a Dict, got {data}")
|
||||
return data
|
||||
|
||||
|
||||
class DynamicFilterExpression:
|
||||
def __init__(self, parser: Parser, expr_str: str) -> None:
|
||||
if not is_dynamic_expression(expr_str):
|
||||
raise TemplateSyntaxError(f"Not a valid dynamic expression: '{expr_str}'")
|
||||
|
||||
# Drop the leading and trailing quote
|
||||
self.expr = expr_str[1:-1]
|
||||
|
||||
# Copy the Parser, and pass through the tags and filters available
|
||||
# in the current context. Thus, if user calls `{% load %}` inside
|
||||
# the expression, it won't spill outside.
|
||||
lexer = Lexer(self.expr)
|
||||
tokens = lexer.tokenize()
|
||||
expr_parser = Parser(tokens=tokens)
|
||||
expr_parser.tags = {**parser.tags}
|
||||
expr_parser.filters = {**parser.filters}
|
||||
|
||||
self.nodelist = expr_parser.parse()
|
||||
|
||||
def resolve(self, context: Context) -> Any:
|
||||
# If the expression consists of a single node, we return the node's value
|
||||
# directly, skipping stringification that would occur by rendering the node
|
||||
# via nodelist.
|
||||
#
|
||||
# This make is possible to pass values from the nested tag expressions
|
||||
# and use them as component inputs.
|
||||
# E.g. below, the value of `value_from_tag` kwarg would be a dictionary,
|
||||
# not a string.
|
||||
#
|
||||
# `{% component "my_comp" value_from_tag="{% gen_dict %}" %}`
|
||||
#
|
||||
# But if it already container spaces, e.g.
|
||||
#
|
||||
# `{% component "my_comp" value_from_tag=" {% gen_dict %} " %}`
|
||||
#
|
||||
# Then we'd treat it as a regular template and pass it as string.
|
||||
if len(self.nodelist) == 1:
|
||||
node = self.nodelist[0]
|
||||
|
||||
# Handle `{{ }}` tags, where we need to access the expression directly
|
||||
# to avoid it being stringified
|
||||
if isinstance(node, VariableNode):
|
||||
return node.filter_expression.resolve(context)
|
||||
else:
|
||||
# For any other tags `{% %}`, we're at a mercy of the authors, and
|
||||
# we don't know if the result comes out stringified or not.
|
||||
return node.render(context)
|
||||
else:
|
||||
# Lastly, if there's multiple nodes, we render it to a string
|
||||
#
|
||||
# NOTE: When rendering a NodeList, it expects that each node is a string.
|
||||
# However, we want to support tags that return non-string results, so we can pass
|
||||
# them as inputs to components. So we wrap the nodes in `StringifiedNode`
|
||||
nodelist = NodeList(StringifiedNode(node) for node in self.nodelist)
|
||||
return nodelist.render(context)
|
||||
|
||||
|
||||
class StringifiedNode(Node):
|
||||
def __init__(self, wrapped_node: Node) -> None:
|
||||
super().__init__()
|
||||
self.wrapped_node = wrapped_node
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
result = self.wrapped_node.render(context)
|
||||
return str(result)
|
||||
|
||||
|
||||
class RuntimeKwargs:
|
||||
|
@ -109,6 +179,34 @@ def is_aggregate_key(key: str) -> bool:
|
|||
return ":" in key and not key.startswith(":")
|
||||
|
||||
|
||||
# A string that must start and end with quotes, and somewhere inside includes
|
||||
# at least one tag. Tag may be variable (`{{ }}`), block (`{% %}`), or comment (`{# #}`).
|
||||
DYNAMIC_EXPR_RE = re.compile(
|
||||
r"^{start_quote}.*?(?:{var_tag}|{block_tag}|{comment_tag}).*?{end_quote}$".format(
|
||||
var_tag=r"(?:\{\{.*?\}\})",
|
||||
block_tag=r"(?:\{%.*?%\})",
|
||||
comment_tag=r"(?:\{#.*?#\})",
|
||||
start_quote=r"(?P<quote>['\"])", # NOTE: Capture group so we check for the same quote at the end
|
||||
end_quote=r"(?P=quote)",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_dynamic_expression(value: Any) -> bool:
|
||||
# NOTE: Currently dynamic expression need at least 6 characters
|
||||
# for the opening and closing tags, and quotes
|
||||
MIN_EXPR_LEN = 6
|
||||
|
||||
if not isinstance(value, str) or not value or len(value) < MIN_EXPR_LEN:
|
||||
return False
|
||||
|
||||
# Is not wrapped in quotes, or does not contain any tags
|
||||
if not DYNAMIC_EXPR_RE.match(value):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def is_spread_operator(value: Any) -> bool:
|
||||
if not isinstance(value, str) or not value:
|
||||
return False
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Union
|
||||
|
||||
import django.template
|
||||
from django.template.base import FilterExpression, NodeList, Parser, Token
|
||||
from django.template.base import FilterExpression, 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
|
||||
|
||||
from django_components.app_settings import ContextBehavior, app_settings
|
||||
from django_components.attributes import HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY, HtmlAttrsNode
|
||||
|
@ -11,6 +12,7 @@ from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
|
|||
from django_components.component_registry import ComponentRegistry
|
||||
from django_components.component_registry import registry as component_registry
|
||||
from django_components.expression import (
|
||||
DynamicFilterExpression,
|
||||
Expression,
|
||||
Operator,
|
||||
RuntimeKwargPairs,
|
||||
|
@ -19,6 +21,7 @@ from django_components.expression import (
|
|||
RuntimeKwargsInput,
|
||||
SpreadOperator,
|
||||
is_aggregate_key,
|
||||
is_dynamic_expression,
|
||||
is_internal_spread_operator,
|
||||
is_kwarg,
|
||||
is_spread_operator,
|
||||
|
@ -132,11 +135,10 @@ def component_js_dependencies(preload: str = "") -> SafeString:
|
|||
|
||||
@register.tag("slot")
|
||||
def slot(parser: Parser, token: Token) -> SlotNode:
|
||||
bits = token.split_contents()
|
||||
tag = _parse_tag(
|
||||
"slot",
|
||||
parser,
|
||||
bits,
|
||||
token,
|
||||
params=["name"],
|
||||
flags=[SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD],
|
||||
keywordonly_kwargs=True,
|
||||
|
@ -171,11 +173,10 @@ def fill(parser: Parser, token: Token) -> FillNode:
|
|||
This tag is available only within a {% component %}..{% endcomponent %} block.
|
||||
Runtime checks should prohibit other usages.
|
||||
"""
|
||||
bits = token.split_contents()
|
||||
tag = _parse_tag(
|
||||
"fill",
|
||||
parser,
|
||||
bits,
|
||||
token,
|
||||
params=["name"],
|
||||
keywordonly_kwargs=[SLOT_DATA_KWARG, SLOT_DEFAULT_KWARG],
|
||||
repeatable_kwargs=False,
|
||||
|
@ -210,6 +211,7 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
|
|||
be either the first positional argument or, if there are no positional
|
||||
arguments, passed as 'name'.
|
||||
"""
|
||||
_fix_nested_tags(parser, token)
|
||||
bits = token.split_contents()
|
||||
|
||||
# Let the TagFormatter pre-process the tokens
|
||||
|
@ -219,11 +221,12 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
|
|||
|
||||
# NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself
|
||||
bits = [bits[0], *result.tokens]
|
||||
token.contents = " ".join(bits)
|
||||
|
||||
tag = _parse_tag(
|
||||
tag_name,
|
||||
parser,
|
||||
bits,
|
||||
token,
|
||||
params=True, # Allow many args
|
||||
flags=["only"],
|
||||
keywordonly_kwargs=True,
|
||||
|
@ -260,11 +263,10 @@ def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
|
|||
@register.tag("provide")
|
||||
def provide(parser: Parser, token: Token) -> ProvideNode:
|
||||
# e.g. {% provide <name> key=val key2=val2 %}
|
||||
bits = token.split_contents()
|
||||
tag = _parse_tag(
|
||||
"provide",
|
||||
parser,
|
||||
bits,
|
||||
token,
|
||||
params=["name"],
|
||||
flags=[],
|
||||
keywordonly_kwargs=True,
|
||||
|
@ -315,12 +317,10 @@ def html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
|
|||
{% html_attrs attrs defaults:class="default-class" class="extra-class" data-id="123" %}
|
||||
```
|
||||
"""
|
||||
bits = token.split_contents()
|
||||
|
||||
tag = _parse_tag(
|
||||
"html_attrs",
|
||||
parser,
|
||||
bits,
|
||||
token,
|
||||
params=[HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY],
|
||||
optional_params=[HTML_ATTRS_ATTRS_KEY, HTML_ATTRS_DEFAULTS_KEY],
|
||||
flags=[],
|
||||
|
@ -350,7 +350,7 @@ class ParsedTag(NamedTuple):
|
|||
def _parse_tag(
|
||||
tag: str,
|
||||
parser: Parser,
|
||||
bits: List[str],
|
||||
token: Token,
|
||||
params: Union[List[str], bool] = False,
|
||||
flags: Optional[List[str]] = None,
|
||||
end_tag: Optional[str] = None,
|
||||
|
@ -364,8 +364,10 @@ def _parse_tag(
|
|||
|
||||
params = params or []
|
||||
|
||||
_fix_nested_tags(parser, token)
|
||||
|
||||
# e.g. {% slot <name> ... %}
|
||||
tag_name, *bits = bits
|
||||
tag_name, *bits = token.split_contents()
|
||||
if tag_name != tag:
|
||||
raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag}'")
|
||||
|
||||
|
@ -471,7 +473,7 @@ def _parse_tag(
|
|||
params = [param for param in params_to_sort if param not in optional_params]
|
||||
|
||||
# Parse args/kwargs that will be passed to the fill
|
||||
args, raw_kwarg_pairs = parse_bits(
|
||||
raw_args, raw_kwarg_pairs = parse_bits(
|
||||
parser=parser,
|
||||
bits=bits,
|
||||
params=[] if isinstance(params, bool) else params,
|
||||
|
@ -480,13 +482,27 @@ def _parse_tag(
|
|||
|
||||
# Post-process args/kwargs - Mark special cases like aggregate dicts
|
||||
# or dynamic expressions
|
||||
args: List[Expression] = []
|
||||
for val in raw_args:
|
||||
if is_dynamic_expression(val.token):
|
||||
args.append(DynamicFilterExpression(parser, val.token))
|
||||
else:
|
||||
args.append(val)
|
||||
|
||||
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)
|
||||
# Allow to use dynamic expressions with spread operator, e.g.
|
||||
# `..."{{ }}"`
|
||||
if is_dynamic_expression(val.token):
|
||||
expr = DynamicFilterExpression(parser, val.token)
|
||||
else:
|
||||
expr = parser.compile_filter(val.token)
|
||||
kwarg_pairs.append((key, SpreadOperator(expr)))
|
||||
elif is_dynamic_expression(val.token) and not is_spread_op:
|
||||
kwarg_pairs.append((key, DynamicFilterExpression(parser, val.token)))
|
||||
else:
|
||||
kwarg_pairs.append((key, val))
|
||||
|
||||
|
@ -560,6 +576,106 @@ def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
|
|||
return body
|
||||
|
||||
|
||||
def _fix_nested_tags(parser: Parser, block_token: Token) -> None:
|
||||
# When our template tag contains a nested tag, e.g.:
|
||||
# `{% component 'test' "{% lorem var_a w %}"`
|
||||
#
|
||||
# Django parses this into:
|
||||
# `TokenType.BLOCK: 'component 'test' "{% lorem var_a w'`
|
||||
#
|
||||
# Above you can see that the token ends at the end of the NESTED tag,
|
||||
# and includes `{%`. So that's what we use to identify if we need to fix
|
||||
# nested tags or not.
|
||||
has_unclosed_tag = block_token.contents.count("{%") > block_token.contents.count("%}")
|
||||
|
||||
# Moreover we need to also check for unclosed quotes for this edge case:
|
||||
# `{% component 'test' "{%}" %}`
|
||||
#
|
||||
# Which Django parses this into:
|
||||
# `TokenType.BLOCK: 'component 'test' "{'`
|
||||
#
|
||||
# Here we cannot see any unclosed tags, but there is an unclosed double quote at the end.
|
||||
#
|
||||
# But we cannot naively search the full contents for unclosed quotes, but
|
||||
# only within the last 'bit'. Consider this:
|
||||
# `{% component 'test' '"' "{%}" %}`
|
||||
#
|
||||
# There is 3 double quotes, but if the contents get split at the first `%}`
|
||||
# then there will be a single unclosed double quote in the last bit.
|
||||
# Hence, for this we use Django's `smart_split()`, which can detect quoted text.
|
||||
last_bit = list(smart_split(block_token.contents))[-1]
|
||||
has_unclosed_quote = last_bit.count("'") % 2 or last_bit.count('"') % 2
|
||||
|
||||
needs_fixing = has_unclosed_tag or has_unclosed_quote
|
||||
|
||||
if not needs_fixing:
|
||||
return
|
||||
|
||||
block_token.contents += "%}" if has_unclosed_quote else " %}"
|
||||
expects_text = True
|
||||
while True:
|
||||
# This is where we need to take parsing in our own hands, because Django parser parsed
|
||||
# only up to the first closing tag `%}`, but that closing tag corresponds to a nested tag,
|
||||
# and not to the end of the outer template tag.
|
||||
#
|
||||
# NOTE: If we run out of tokens, this will raise, and break out of the loop
|
||||
token = parser.next_token()
|
||||
|
||||
# If there is a nested BLOCK `{% %}`, VAR `{{ }}`, or COMMENT `{# #}` tag inside the template tag,
|
||||
# then the way Django parses it results in alternating Tokens of TEXT and non-TEXT types.
|
||||
#
|
||||
# We use `expects_text` to know which type to handle.
|
||||
if expects_text:
|
||||
if token.token_type != TokenType.TEXT:
|
||||
raise TemplateSyntaxError(f"Template parser received TokenType '{token.token_type}' instead of 'TEXT'")
|
||||
|
||||
expects_text = False
|
||||
|
||||
# Once we come across a closing tag in the text, we know that's our original
|
||||
# end tag. Until then, append all the text to the block token and continue
|
||||
if "%}" not in token.contents:
|
||||
block_token.contents += token.contents
|
||||
continue
|
||||
|
||||
# This is the ACTUAL end of the block template tag
|
||||
remaining_block_content, text_content = token.contents.split("%}", 1)
|
||||
block_token.contents += remaining_block_content
|
||||
|
||||
# We put back into the Parser the remaining bit of the text.
|
||||
# NOTE: Looking at the implementation, `parser.prepend_token()` is the opposite
|
||||
# of `parser.next_token()`.
|
||||
parser.prepend_token(Token(TokenType.TEXT, contents=text_content))
|
||||
break
|
||||
|
||||
# In this case we've come across a next block tag `{% %}` inside the template tag
|
||||
# This isn't the first occurence, where the `{%` was ignored. And so, the content
|
||||
# between the `{% %}` is correctly captured, e.g.
|
||||
#
|
||||
# `{% firstof False 0 is_active %}`
|
||||
# gives
|
||||
# `TokenType.BLOCK: 'firstof False 0 is_active'`
|
||||
#
|
||||
# But we don't want to evaluate this as a standalone BLOCK tag, and instead append
|
||||
# it to the block tag that this nested block is part of
|
||||
else:
|
||||
if token.token_type == TokenType.TEXT:
|
||||
raise TemplateSyntaxError(
|
||||
f"Template parser received TokenType '{token.token_type}' instead of 'BLOCK', 'VAR', 'COMMENT'"
|
||||
)
|
||||
|
||||
if token.token_type == TokenType.BLOCK:
|
||||
block_token.contents += "{% " + token.contents + " %}"
|
||||
elif token.token_type == TokenType.VAR:
|
||||
block_token.contents += "{{ " + token.contents + " }}"
|
||||
elif token.token_type == TokenType.COMMENT:
|
||||
pass # Comments are ignored
|
||||
else:
|
||||
raise TemplateSyntaxError(f"Unknown token type '{token.token_type}'")
|
||||
|
||||
expects_text = True
|
||||
continue
|
||||
|
||||
|
||||
class ParsedSlotTag(NamedTuple):
|
||||
name: str
|
||||
|
||||
|
|
|
@ -3,10 +3,10 @@
|
|||
from typing import Any, Dict
|
||||
|
||||
from django.template import Context, Template, TemplateSyntaxError
|
||||
from django.template.base import Parser
|
||||
from django.template.base import FilterExpression, Node, Parser, Token
|
||||
|
||||
from django_components import Component, register, types
|
||||
from django_components.expression import safe_resolve_dict, safe_resolve_list
|
||||
from django_components import Component, register, registry, types
|
||||
from django_components.expression import DynamicFilterExpression, safe_resolve_dict, safe_resolve_list
|
||||
|
||||
from .django_test_setup import setup_test_config
|
||||
from .testutils import BaseTestCase, parametrize_context_behavior
|
||||
|
@ -18,6 +18,23 @@ engine = Template("").engine
|
|||
default_parser = Parser("", engine.template_libraries, engine.template_builtins)
|
||||
|
||||
|
||||
# A tag that just returns the value, so we can
|
||||
# check if the value is stringified
|
||||
class NoopNode(Node):
|
||||
def __init__(self, expr: FilterExpression):
|
||||
self.expr = expr
|
||||
|
||||
def render(self, context: Context):
|
||||
return self.expr.resolve(context)
|
||||
|
||||
|
||||
def noop(parser: Parser, token: Token):
|
||||
tag, raw_expr = token.split_contents()
|
||||
expr = parser.compile_filter(raw_expr)
|
||||
|
||||
return NoopNode(expr)
|
||||
|
||||
|
||||
def make_context(d: Dict):
|
||||
ctx = Context(d)
|
||||
ctx.template = Template("")
|
||||
|
@ -61,6 +78,389 @@ class ResolveTests(BaseTestCase):
|
|||
)
|
||||
|
||||
|
||||
# NOTE: Django calls the `{{ }}` syntax "variables" and `{% %}` "blocks"
|
||||
class DynamicExprTests(BaseTestCase):
|
||||
def test_variable_resolve_dynamic_expr(self):
|
||||
expr = DynamicFilterExpression(default_parser, '"{{ var_a|lower }}"')
|
||||
|
||||
ctx = make_context({"var_a": "LoREM"})
|
||||
self.assertEqual(expr.resolve(ctx), "lorem")
|
||||
|
||||
def test_variable_raises_on_dynamic_expr_with_quotes_mismatch(self):
|
||||
with self.assertRaises(TemplateSyntaxError):
|
||||
DynamicFilterExpression(default_parser, "'{{ var_a|lower }}\"")
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_variable_in_template(self):
|
||||
captured = {}
|
||||
|
||||
@register("test")
|
||||
class SimpleComponent(Component):
|
||||
def get_context_data(
|
||||
self,
|
||||
pos_var1: Any,
|
||||
*args: Any,
|
||||
bool_var: bool,
|
||||
list_var: Dict,
|
||||
):
|
||||
captured["pos_var1"] = pos_var1
|
||||
captured["bool_var"] = bool_var
|
||||
captured["list_var"] = list_var
|
||||
|
||||
return {
|
||||
"pos_var1": pos_var1,
|
||||
"bool_var": bool_var,
|
||||
"list_var": list_var,
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
<div>{{ pos_var1 }}</div>
|
||||
<div>{{ bool_var }}</div>
|
||||
<div>{{ list_var|safe }}</div>
|
||||
"""
|
||||
|
||||
template_str: types.django_html = (
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component 'test'
|
||||
"{{ var_a|lower }}"
|
||||
bool_var="{{ is_active }}"
|
||||
list_var="{{ list|slice:':-1' }}"
|
||||
/ %}
|
||||
""".replace(
|
||||
"\n", " "
|
||||
)
|
||||
)
|
||||
|
||||
template = Template(template_str)
|
||||
rendered = template.render(
|
||||
Context(
|
||||
{
|
||||
"var_a": "LoREM",
|
||||
"is_active": True,
|
||||
"list": [{"a": 1}, {"a": 2}, {"a": 3}],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Check that variables passed to the component are of correct type
|
||||
self.assertEqual(captured["pos_var1"], "lorem")
|
||||
self.assertEqual(captured["bool_var"], True)
|
||||
self.assertEqual(captured["list_var"], [{"a": 1}, {"a": 2}])
|
||||
|
||||
self.assertEqual(
|
||||
rendered.strip(),
|
||||
"<div>lorem</div>\n <div>True</div>\n <div>[{'a': 1}, {'a': 2}]</div>",
|
||||
)
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_block_in_template(self):
|
||||
registry.library.tag(noop)
|
||||
captured = {}
|
||||
|
||||
@register("test")
|
||||
class SimpleComponent(Component):
|
||||
def get_context_data(
|
||||
self,
|
||||
pos_var1: Any,
|
||||
*args: Any,
|
||||
bool_var: bool,
|
||||
list_var: Dict,
|
||||
dict_var: Dict,
|
||||
):
|
||||
captured["pos_var1"] = pos_var1
|
||||
captured["bool_var"] = bool_var
|
||||
captured["list_var"] = list_var
|
||||
captured["dict_var"] = dict_var
|
||||
|
||||
return {
|
||||
"pos_var1": pos_var1,
|
||||
"bool_var": bool_var,
|
||||
"list_var": list_var,
|
||||
"dict_var": dict_var,
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
<div>{{ pos_var1 }}</div>
|
||||
<div>{{ bool_var }}</div>
|
||||
<div>{{ list_var|safe }}</div>
|
||||
<div>{{ dict_var|safe }}</div>
|
||||
"""
|
||||
|
||||
template_str: types.django_html = (
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component 'test'
|
||||
"{% lorem var_a w %}"
|
||||
bool_var="{% noop is_active %}"
|
||||
list_var="{% noop list %}"
|
||||
dict_var="{% noop dict %}"
|
||||
/ %}
|
||||
""".replace(
|
||||
"\n", " "
|
||||
)
|
||||
)
|
||||
|
||||
template = Template(template_str)
|
||||
rendered = template.render(
|
||||
Context(
|
||||
{
|
||||
"var_a": 3,
|
||||
"is_active": True,
|
||||
"list": [{"a": 1}, {"a": 2}],
|
||||
"dict": {"a": 3},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Check that variables passed to the component are of correct type
|
||||
self.assertEqual(captured["bool_var"], True)
|
||||
self.assertEqual(captured["dict_var"], {"a": 3})
|
||||
self.assertEqual(captured["list_var"], [{"a": 1}, {"a": 2}])
|
||||
|
||||
self.assertEqual(
|
||||
rendered.strip(),
|
||||
"<div>lorem ipsum dolor</div>\n <div>True</div>\n <div>[{'a': 1}, {'a': 2}]</div>\n <div>{'a': 3}</div>", # noqa E501
|
||||
)
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_comment_in_template(self):
|
||||
registry.library.tag(noop)
|
||||
captured = {}
|
||||
|
||||
@register("test")
|
||||
class SimpleComponent(Component):
|
||||
def get_context_data(
|
||||
self,
|
||||
pos_var1: Any,
|
||||
pos_var2: Any,
|
||||
*args: Any,
|
||||
bool_var: bool,
|
||||
list_var: Dict,
|
||||
):
|
||||
captured["pos_var1"] = pos_var1
|
||||
captured["pos_var2"] = pos_var2
|
||||
captured["bool_var"] = bool_var
|
||||
captured["list_var"] = list_var
|
||||
|
||||
return {
|
||||
"pos_var1": pos_var1,
|
||||
"pos_var2": pos_var2,
|
||||
"bool_var": bool_var,
|
||||
"list_var": list_var,
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
<div>{{ pos_var1 }}</div>
|
||||
<div>{{ pos_var2 }}</div>
|
||||
<div>{{ bool_var }}</div>
|
||||
<div>{{ list_var|safe }}</div>
|
||||
"""
|
||||
|
||||
template_str: types.django_html = (
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component 'test'
|
||||
"{# lorem var_a w #}"
|
||||
" {# lorem var_a w #} abc"
|
||||
bool_var="{# noop is_active #}"
|
||||
list_var=" {# noop list #} "
|
||||
/ %}
|
||||
""".replace(
|
||||
"\n", " "
|
||||
)
|
||||
)
|
||||
|
||||
template = Template(template_str)
|
||||
rendered = template.render(
|
||||
Context(
|
||||
{
|
||||
"var_a": 3,
|
||||
"is_active": True,
|
||||
"list": [{"a": 1}, {"a": 2}],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Check that variables passed to the component are of correct type
|
||||
self.assertEqual(captured["pos_var1"], "")
|
||||
self.assertEqual(captured["pos_var2"], " abc")
|
||||
self.assertEqual(captured["bool_var"], "")
|
||||
self.assertEqual(captured["list_var"], " ")
|
||||
|
||||
self.assertEqual(
|
||||
rendered.strip(),
|
||||
"<div></div>\n <div> abc</div>\n <div></div>\n <div> </div>", # noqa E501
|
||||
)
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_mixed_in_template(self):
|
||||
registry.library.tag(noop)
|
||||
captured = {}
|
||||
|
||||
@register("test")
|
||||
class SimpleComponent(Component):
|
||||
def get_context_data(
|
||||
self,
|
||||
pos_var1: Any,
|
||||
pos_var2: Any,
|
||||
*args: Any,
|
||||
bool_var: bool,
|
||||
list_var: Dict,
|
||||
dict_var: Dict,
|
||||
):
|
||||
captured["pos_var1"] = pos_var1
|
||||
captured["bool_var"] = bool_var
|
||||
captured["list_var"] = list_var
|
||||
captured["dict_var"] = dict_var
|
||||
|
||||
return {
|
||||
"pos_var1": pos_var1,
|
||||
"pos_var2": pos_var2,
|
||||
"bool_var": bool_var,
|
||||
"list_var": list_var,
|
||||
"dict_var": dict_var,
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
<div>{{ pos_var1 }}</div>
|
||||
<div>{{ pos_var2 }}</div>
|
||||
<div>{{ bool_var }}</div>
|
||||
<div>{{ list_var|safe }}</div>
|
||||
<div>{{ dict_var|safe }}</div>
|
||||
"""
|
||||
|
||||
template_str: types.django_html = (
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component 'test'
|
||||
" {% lorem var_a w %} "
|
||||
" {% lorem var_a w %} {{ list|slice:':-1' }} "
|
||||
bool_var=" {% noop is_active %} "
|
||||
list_var=" {% noop list %} "
|
||||
dict_var=" {% noop dict %} "
|
||||
/ %}
|
||||
""".replace(
|
||||
"\n", " "
|
||||
)
|
||||
)
|
||||
|
||||
template = Template(template_str)
|
||||
rendered = template.render(
|
||||
Context(
|
||||
{
|
||||
"var_a": 3,
|
||||
"is_active": True,
|
||||
"list": [{"a": 1}, {"a": 2}],
|
||||
"dict": {"a": 3},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Check that variables passed to the component are of correct type
|
||||
self.assertEqual(captured["bool_var"], " True ")
|
||||
self.assertEqual(captured["dict_var"], " {'a': 3} ")
|
||||
self.assertEqual(captured["list_var"], " [{'a': 1}, {'a': 2}] ")
|
||||
|
||||
self.assertEqual(
|
||||
rendered.strip(),
|
||||
"<div> lorem ipsum dolor </div>\n <div> lorem ipsum dolor [{'a': 1}] </div>\n <div> True </div>\n <div> [{'a': 1}, {'a': 2}] </div>\n <div> {'a': 3} </div>", # noqa E501
|
||||
)
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_ignores_invalid_tag(self):
|
||||
registry.library.tag(noop)
|
||||
|
||||
@register("test")
|
||||
class SimpleComponent(Component):
|
||||
def get_context_data(
|
||||
self,
|
||||
pos_var1: Any,
|
||||
pos_var2: Any,
|
||||
*args: Any,
|
||||
bool_var: bool,
|
||||
):
|
||||
return {
|
||||
"pos_var1": pos_var1,
|
||||
"pos_var2": pos_var2,
|
||||
"bool_var": bool_var,
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
<div>{{ pos_var1 }}</div>
|
||||
<div>{{ pos_var2 }}</div>
|
||||
<div>{{ bool_var }}</div>
|
||||
"""
|
||||
|
||||
template_str: types.django_html = (
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component 'test' '"' "{%}" bool_var="{% noop is_active %}" / %}
|
||||
""".replace(
|
||||
"\n", " "
|
||||
)
|
||||
)
|
||||
|
||||
template = Template(template_str)
|
||||
rendered = template.render(
|
||||
Context({"is_active": True}),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
rendered.strip(),
|
||||
'<div>"</div>\n <div>{%}</div>\n <div>True</div>',
|
||||
)
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_nested_in_template(self):
|
||||
registry.library.tag(noop)
|
||||
|
||||
@register("test")
|
||||
class SimpleComponent(Component):
|
||||
def get_context_data(
|
||||
self,
|
||||
pos_var1: Any,
|
||||
*args: Any,
|
||||
bool_var: bool,
|
||||
):
|
||||
return {
|
||||
"pos_var1": pos_var1,
|
||||
"bool_var": bool_var,
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
<div>{{ pos_var1 }}</div>
|
||||
<div>{{ bool_var }}</div>
|
||||
"""
|
||||
|
||||
template_str: types.django_html = (
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component 'test'
|
||||
"{% component 'test' '{{ var_a }}' bool_var=is_active / %}"
|
||||
bool_var="{% noop is_active %}"
|
||||
/ %}
|
||||
""".replace(
|
||||
"\n", " "
|
||||
)
|
||||
)
|
||||
|
||||
template = Template(template_str)
|
||||
rendered = template.render(
|
||||
Context(
|
||||
{
|
||||
"var_a": 3,
|
||||
"is_active": True,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
rendered.strip(),
|
||||
"<div>\n <div>3</div>\n <div>True</div>\n </div>\n <div>True</div>", # noqa E501
|
||||
)
|
||||
|
||||
|
||||
class SpreadOperatorTests(BaseTestCase):
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_component(self):
|
||||
|
@ -96,7 +496,7 @@ class SpreadOperatorTests(BaseTestCase):
|
|||
{% component 'test'
|
||||
var_a
|
||||
...my_dict
|
||||
...item
|
||||
..."{{ list|first }}"
|
||||
x=123
|
||||
/ %}
|
||||
""".replace(
|
||||
|
@ -114,7 +514,7 @@ class SpreadOperatorTests(BaseTestCase):
|
|||
"attrs:style": "height: 20px",
|
||||
"items": [1, 2, 3],
|
||||
},
|
||||
"item": {"a": 1},
|
||||
"list": [{"a": 1}, {"a": 2}, {"a": 3}],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
@ -147,12 +547,12 @@ class SpreadOperatorTests(BaseTestCase):
|
|||
"attrs:style": "height: 20px",
|
||||
"items": [1, 2, 3],
|
||||
},
|
||||
"item": {"a": 1},
|
||||
"list": [{"a": 1}, {"a": 2}, {"a": 3}],
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% slot "my_slot" ...my_dict ...item x=123 default / %}
|
||||
{% slot "my_slot" ...my_dict ..."{{ list|first }}" x=123 default / %}
|
||||
"""
|
||||
|
||||
template_str: types.django_html = """
|
||||
|
@ -184,12 +584,12 @@ class SpreadOperatorTests(BaseTestCase):
|
|||
"attrs:style": "height: 20px",
|
||||
"items": [1, 2, 3],
|
||||
},
|
||||
"item": {"a": 1},
|
||||
"list": [{"a": 1}, {"a": 2}, {"a": 3}],
|
||||
}
|
||||
|
||||
template: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% slot "my_slot" ...my_dict ...item x=123 default %}
|
||||
{% slot "my_slot" ...my_dict ..."{{ list|first }}" x=123 default %}
|
||||
__SLOT_DEFAULT__
|
||||
{% endslot %}
|
||||
"""
|
||||
|
@ -244,7 +644,7 @@ class SpreadOperatorTests(BaseTestCase):
|
|||
|
||||
template_str: types.django_html = """
|
||||
{% load component_tags %}
|
||||
{% provide 'test' ...my_dict ...item %}
|
||||
{% provide 'test' ...my_dict ..."{{ list|first }}" %}
|
||||
{% component 'test' / %}
|
||||
{% endprovide %}
|
||||
"""
|
||||
|
@ -257,7 +657,7 @@ class SpreadOperatorTests(BaseTestCase):
|
|||
"attrs:style": "height: 20px",
|
||||
"items": [1, 2, 3],
|
||||
},
|
||||
"item": {"a": 1},
|
||||
"list": [{"a": 1}, {"a": 2}, {"a": 3}],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
@ -324,7 +724,7 @@ class SpreadOperatorTests(BaseTestCase):
|
|||
...my_dict
|
||||
attrs:style="OVERWRITTEN"
|
||||
x=123
|
||||
...item
|
||||
..."{{ list|first }}"
|
||||
/ %}
|
||||
""".replace(
|
||||
"\n", " "
|
||||
|
@ -340,7 +740,7 @@ class SpreadOperatorTests(BaseTestCase):
|
|||
"attrs:style": "height: 20px",
|
||||
"items": [1, 2, 3],
|
||||
},
|
||||
"item": {"a": 1, "x": "OVERWRITTEN_X"},
|
||||
"list": [{"a": 1, "x": "OVERWRITTEN_X"}, {"a": 2}, {"a": 3}],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
@ -356,7 +756,7 @@ class SpreadOperatorTests(BaseTestCase):
|
|||
)
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_raises_if_position_arg_after_spread(self):
|
||||
def test_raises_if_positional_arg_after_spread(self):
|
||||
@register("test")
|
||||
class SimpleComponent(Component):
|
||||
pass
|
||||
|
@ -367,7 +767,7 @@ class SpreadOperatorTests(BaseTestCase):
|
|||
{% component 'test'
|
||||
...my_dict
|
||||
var_a
|
||||
...item
|
||||
..."{{ list|first }}"
|
||||
x=123
|
||||
/ %}
|
||||
""".replace(
|
||||
|
@ -422,7 +822,9 @@ class SpreadOperatorTests(BaseTestCase):
|
|||
template = Template(template_str)
|
||||
|
||||
# List
|
||||
with self.assertRaisesMessage(AttributeError, "'list' object has no attribute 'items'"):
|
||||
with self.assertRaisesMessage(
|
||||
RuntimeError, "Spread operator expression must resolve to a Dict, got [1, 2, 3]"
|
||||
):
|
||||
template.render(
|
||||
Context(
|
||||
{
|
||||
|
@ -433,7 +835,7 @@ class SpreadOperatorTests(BaseTestCase):
|
|||
)
|
||||
|
||||
# String
|
||||
with self.assertRaisesMessage(AttributeError, "'str' object has no attribute 'items'"):
|
||||
with self.assertRaisesMessage(RuntimeError, "Spread operator expression must resolve to a Dict, got def"):
|
||||
template.render(
|
||||
Context(
|
||||
{
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
from django.template import Context, Template
|
||||
from django.template.base import Parser
|
||||
from django.template.base import Lexer, Parser
|
||||
|
||||
from django_components import Component, registry, types
|
||||
from django_components.expression import (
|
||||
|
@ -18,8 +18,10 @@ setup_test_config({"autodiscover": False})
|
|||
|
||||
class ParserTest(BaseTestCase):
|
||||
def test_parses_args_kwargs(self):
|
||||
bits = ["component", "42", "myvar", "key='val'", "key2=val2"]
|
||||
tag = _parse_tag("component", Parser(""), bits, params=["num", "var"], keywordonly_kwargs=True)
|
||||
template_str = "{% component 42 myvar key='val' key2=val2 %}"
|
||||
tokens = Lexer(template_str).tokenize()
|
||||
parser = Parser(tokens=tokens)
|
||||
tag = _parse_tag("component", parser, parser.tokens[0], params=["num", "var"], keywordonly_kwargs=True)
|
||||
|
||||
ctx = {"myvar": {"a": "b"}, "val2": 1}
|
||||
args = safe_resolve_list(ctx, tag.args)
|
||||
|
@ -31,15 +33,10 @@ class ParserTest(BaseTestCase):
|
|||
self.assertDictEqual(kwargs, {"key": "val", "key2": 1})
|
||||
|
||||
def test_parses_special_kwargs(self):
|
||||
bits = [
|
||||
"component",
|
||||
"date=date",
|
||||
"@lol=2",
|
||||
"na-me=bzz",
|
||||
"@event:na-me.mod=bzz",
|
||||
"#my-id=True",
|
||||
]
|
||||
tag = _parse_tag("component", Parser(""), bits, keywordonly_kwargs=True)
|
||||
template_str = "{% component date=date @lol=2 na-me=bzz @event:na-me.mod=bzz #my-id=True %}"
|
||||
tokens = Lexer(template_str).tokenize()
|
||||
parser = Parser(tokens=tokens)
|
||||
tag = _parse_tag("component", parser, parser.tokens[0], keywordonly_kwargs=True)
|
||||
|
||||
ctx = Context({"date": 2024, "bzz": "fzz"})
|
||||
args = safe_resolve_list(ctx, tag.args)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue