mirror of
https://github.com/django-components/django-components.git
synced 2025-10-17 09:17:10 +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
|
@ -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(
|
||||
{
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue