diff --git a/README.md b/README.md index ac83cb3e..b4979cc9 100644 --- a/README.md +++ b/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) - [Passing data to components](#passing-data-to-components) - [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) - [Component context and scope](#component-context-and-scope) - [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: ``` -### Special characters - -_New in version 0.71_: - -Keyword arguments can contain special characters `# @ . - _`, so keywords like -so are still valid: - -```django - - {% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True / %} - -``` - -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 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' ``` +## 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 + + {% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True / %} + +``` + +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) _New in version 0.80_: diff --git a/src/django_components/expression.py b/src/django_components/expression.py index 6b762096..4bfb6a6b 100644 --- a/src/django_components/expression.py +++ b/src/django_components/expression.py @@ -1,11 +1,36 @@ +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 Expression = Union[FilterExpression] -RuntimeKwargsInput = Dict[str, Expression] -RuntimeKwargPairsInput = List[Tuple[str, Expression]] +RuntimeKwargsInput = Dict[str, Union[Expression, "Operator"]] +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: @@ -24,7 +49,12 @@ class RuntimeKwargPairs: def resolve(self, context: Context) -> List[Tuple[str, Any]]: resolved_kwarg_pairs: List[Tuple[str, Any]] = [] 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 @@ -43,12 +73,19 @@ def safe_resolve_list(context: Context, args: List[Expression]) -> List: def safe_resolve_dict( context: Context, - kwargs: Dict[str, Expression], + kwargs: Dict[str, Union[Expression, "Operator"]], ) -> Dict[str, Any]: result = {} 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 @@ -72,6 +109,31 @@ def is_aggregate_key(key: str) -> bool: 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]: """ This function aggregates "prefixed" kwargs into dicts. "Prefixed" kwargs diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py index a3a6629e..cc1fc3d1 100644 --- a/src/django_components/templatetags/component_tags.py +++ b/src/django_components/templatetags/component_tags.py @@ -12,11 +12,16 @@ from django_components.component_registry import ComponentRegistry from django_components.component_registry import registry as component_registry from django_components.expression import ( Expression, + Operator, RuntimeKwargPairs, + RuntimeKwargPairsInput, RuntimeKwargs, RuntimeKwargsInput, + SpreadOperator, is_aggregate_key, + is_internal_spread_operator, is_kwarg, + is_spread_operator, resolve_string, ) from django_components.logger import trace_msg @@ -389,6 +394,7 @@ def _parse_tag( else: seen_kwargs.add(key) + spread_count = 0 for bit in bits: value = bit bit_is_kwarg = is_kwarg(bit) @@ -411,6 +417,21 @@ def _parse_tag( parsed_flags[value] = True 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 = bits_without_flags @@ -450,13 +471,25 @@ 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, kwarg_pairs = parse_bits( + args, raw_kwarg_pairs = parse_bits( parser=parser, bits=bits, params=[] if isinstance(params, bool) else params, 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 if params != True and len(args) > len(params): # noqa F712 raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {args[len(params):]}") @@ -471,6 +504,11 @@ def _parse_tag( kwargs: RuntimeKwargsInput = {} extra_keywords: Set[str] = set() 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 if not keywordonly_kwargs: is_key_allowed = False diff --git a/tests/test_expression.py b/tests/test_expression.py index 0e1e1b47..2b5fabb5 100644 --- a/tests/test_expression.py +++ b/tests/test_expression.py @@ -1,14 +1,15 @@ """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_components import Component, register, types from django_components.expression import safe_resolve_dict, safe_resolve_list from .django_test_setup import setup_test_config -from .testutils import BaseTestCase +from .testutils import BaseTestCase, parametrize_context_behavior setup_test_config({"autodiscover": False}) @@ -58,3 +59,386 @@ class ResolveTests(BaseTestCase): safe_resolve_dict(ctx, exprs), {"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 = """ +
{{ pos_var1 }}
+
{{ attrs }}
+
{{ items }}
+
{{ a }}
+
{{ x }}
+ """ + + 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, + """ +
LoREM
+
{'@click': '() => {}', 'style': 'height: 20px'}
+
[1, 2, 3]
+
1
+
123
+ """, + ) + + @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 %} +
{{ attrs }}
+
{{ items }}
+
{{ a }}
+ """ + + 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, + """ +
{'@click': '() => {}', 'style': 'height: 20px'}
+
[1, 2, 3]
+
1
+ """, + ) + + @parametrize_context_behavior(["django", "isolated"]) + def test_html_attrs(self): + template_str: types.django_html = """ + {% load component_tags %} +
+ """ + 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, + """ +
+ """, + ) + + @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 = """ +
{{ attrs }}
+
{{ items }}
+
{{ a }}
+
{{ x }}
+ """ + + 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, + """ +
{'@click': '() => {}', 'style': 'OVERWRITTEN'}
+
[1, 2, 3]
+
1
+
OVERWRITTEN_X
+ """, + ) + + @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", + } + ) + )